参考实现: 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 (); private HttpHeaders serpApiHeader; 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) { 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 () { 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 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 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 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 @SuppressWarnings({"unchecked", "rawtypes"}) public List<Map<String, Object>> getSearchResults (String searchTerm) { String body = "{\"q\":\"" + searchTerm + "\",\"hl\":\"en\",\"num\":10}" ; HttpEntity<String> entity = new HttpEntity <>(body, serpApiHeader); 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 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-8 de0a5ea5c204 . https:// obsidian.md/5 . https:// www.makeuseof.com/what-is-obsidian-note-taking/ 6 . https:// www.cloudwards.net/obsidian-review/ 7 . https:// obsidian.md/plugins8 . https://gi thub.com/obsidianmd/ obsidian-releases9 . 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+Sync12 . https:// help.obsidian.md/Getting+started/ Sync+your+notes+across+devices13 . https:// help.obsidian.md/Obsidian+Sync/ Sync+limitations14 . https:// www.nuclino.com/solutions/ obsidian-vs-notion15 . 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 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 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 br2. Item two3. Item three To create an unordered list, simply use asterisks * Item two * Item three Blockquotes: To create a blockquote, simply type > >
开始根据提供的上下文写作:
1 2 3 4 5 6 System.out.println("\n\n正在写文章..." );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 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("是" )) { 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 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
点击阅读原文即可跳转。