Open Libertyで試すChatGPT連携(2) 企業ニュースを取得して要約する

## はじめに
23年は生成AIが大きなトレンドとなっています。流行のChatGPTも業務アプリケーションとシームレスに繋げて使えると可能性が広がるのではないでしょうか。
「[Open Libertyで試すChatGPT連携](https://qiita.com/mi-ta/items/39975d99a9f68dfe9954)」ではJavaからChatAPI連携を行ないましたが「HelloWorldは食傷気味」という方もいらっしゃるのではないでしょうか。
そこで、この投稿ではもう少しユースケースを想定した、実務をイメージした実装を行います。
## 今回の内容
**指定企業のニュースリリースを参照し、ChatAPIで要約を行う**
という処理を作成します。
使いどころとして、CRMと連動して表示顧客の直近ニュースリリースを要約表示することでビジネスニーズや話題を掴みやすくする、といったシーンが考えられます。
UIとしてはCRM画面起動時にポップアップ表示するなど考えられますが、この投稿ではバックエンド側にフォーカスします。
今回の実装では[日本IBMのニュースリリース](https://jp.newsroom.ibm.com/)をターゲットとします。

ChatAPIとその他データソースを連携して利用するためには
* モデルの継続的なファインチューニング
* ChatAPIのリクエストに、データソースから取得した情報を上乗せする
* ユーザー問合せを事前判断して、Chatを使用するか／データソースの全文検索エンジンを使用するか、どちらかに振り分ける

などのパターンが考えられるかと思います。
今回は2番目のパターンで「事前処理として指定企業のニュースリリースページをクロールしたうえでChatにクロール結果を上乗せする」という処理にします。

## つくってみる
「[Open Libertyで試すChatGPT連携](https://qiita.com/mi-ta/items/39975d99a9f68dfe9954)」で作成したChatAPI連携ソースに今回分の処理を追加する形で実装を進めます。ChatAPI連携についての実装は元記事を確認ねがいます。

#### ライブラリの追加
Crawler4J（Webクロール処理）、Jsoup（HTML解析）の追加を行います。
pom.xmlのdependenciesセクション内に、次の定義を追加します。
```
        <dependency>
            <groupId>edu.uci.ics</groupId>
            <artifactId>crawler4j</artifactId>
            <version>4.1</version>
        </dependency>
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.16.1</version>
        </dependency>
```
#### 作成するクラス
次のクラスおよびメソッドを作成します。
| クラス名 | メソッド名 | 処理内容 |
|:-:|:-:|:-:|
| NewsCrawlConsumer | newsReq | 主処理 |
|  | crawlNews | 指定ページのクローリング |
|  | query | ChatAPIコール |
| NewsCrawler | shouldVisit | 対象ページをクロール対象とするか判断する |
|  | visit | クロール後の処理 |

##### NewsCrawlConsumerクラス
指定サイトをクロールして結果要約をChatAPIで実行するクラスです。
各メソッド内の処理は個別に解説します。

```java:NewsCrawlConsumer.java
package jp.sample.rest;

import java.util.HashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.ArrayList;

import jakarta.json.bind.Jsonb;
import jakarta.json.bind.JsonbBuilder;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;

import edu.uci.ics.crawler4j.crawler.CrawlConfig;
import edu.uci.ics.crawler4j.crawler.CrawlController;
import edu.uci.ics.crawler4j.fetcher.PageFetcher;
import edu.uci.ics.crawler4j.robotstxt.RobotstxtConfig;
import edu.uci.ics.crawler4j.robotstxt.RobotstxtServer;

public class NewsCrawlConsumer {

    public static CopyOnWriteArrayList<HashMap<String, String>> newsList;

    public static String newsReq() {
    }

    public static String query(String newsJson) {
    }

    public static void crawlNews() {
    }

}
```
##### NewsCrawlConsumer.newsReqメソッド
crawlNewsメソッドで参照結果をnewslistに格納し、結果を1件ずつqueryメソッドに渡し要約結果をresponseに蓄積して返却します。
newslistの要素がMapなのはクロールページの様々な情報を格納できるようにするためですが、今回の処理では本文が格納されたキー：textしか使用していません。

```java:NewsCrawlConsumer.newsReq
    public static String newsReq() {

        String response = new String();
        newsList = new CopyOnWriteArrayList<HashMap<String, String>>();

        NewsCrawlConsumer.crawlNews();

        Jsonb jsonb = JsonbBuilder.create();
        for (HashMap<String, String> news :newsList) {
            String newsJson = jsonb.toJson(news);
            response = response + "<BR><BR>========<BR>" + query(newsJson);
        }

        return response;
    }

```

##### NewsCrawlConsumer.crawlNewsメソッド
Crawler4Jの処理定義を行い、クロール処理を実行しています。
**IBMニュースリリースの10件/ページという表示モード**を対象とするため、期待する取得結果は10件になります。
実行結果はnewsListに蓄積します。

```java:NewsCrawlConsumer.crawlNews
    public static void crawlNews() {

        String crawlStorageFolder = "/projects/crawl";
        try {

            CrawlConfig config = new CrawlConfig();
            config.setMaxDepthOfCrawling(1);  // ルートから1階層下まで参照する
            config.setIncludeBinaryContentInCrawling(false);  // バイナリコンテンツは対象外
            config.setResumableCrawling(false); // 障害発生時のレジューム処理OFF
            config.setCrawlStorageFolder(crawlStorageFolder);

            PageFetcher pageFetcher = new PageFetcher(config);
            RobotstxtConfig robotstxtConfig = new RobotstxtConfig();
            RobotstxtServer robotstxtServer = new RobotstxtServer(robotstxtConfig, pageFetcher);
            CrawlController controller = new CrawlController(config, pageFetcher, robotstxtServer);

            controller.addSeed("https://jp.newsroom.ibm.com/announcements?l=10");  // ニュースソース参照エントリポイント
        
            controller.start(NewsCrawler.class, 8);  // 8クローラーで実行

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
```

##### NewsCrawlConsumer.queryメソッド
「[Open Libertyで試すChatGPT連携](https://qiita.com/mi-ta/items/39975d99a9f68dfe9954)」で実装した処理と構造は同じですが、渡すプロンプトが異なります。

```java:NewsCrawlConsumer.query
    public static String query(String newsJson) {

        try(Client client = ClientBuilder.newClient();) {

            String targetURL = "https://api.openai.com/v1/chat/completions";
            ChatRequest req = new ChatRequest();
            req.setModel("gpt-3.5-turbo");

            String requestTxt = "次のJSONデータのtext要素を日本語かつ140文字以内に要約してください。" + newsJson;

            HashMap<String, String> msg = new HashMap<String, String>();
            msg.put("role", "user");
            msg.put("content", requestTxt);
            ArrayList<HashMap<String, String>> msgs = new ArrayList<HashMap<String, String>>();
            msgs.add(msg);
            req.setMessages(msgs);

            ChatResponse res = client
                .target(targetURL)
                .request(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + "あなたが取得したAPIキー")
                .post(Entity.entity(req, MediaType.APPLICATION_JSON), ChatResponse.class);

            return res.getChoices().get(0).getMessage().get("content");

        } catch (Exception e) {
            e.printStackTrace();
            return "Error raised.";
        }
    }
```

##### NewsCrawlerクラス
Crawl4jの実装で必要となる、WebCrawlerの拡張クラスです。

```java:NewsCrawler.java
package jp.sample.rest;

import java.util.HashMap;
import java.util.regex.Pattern;

import edu.uci.ics.crawler4j.crawler.Page;
import edu.uci.ics.crawler4j.crawler.WebCrawler;
import edu.uci.ics.crawler4j.parser.HtmlParseData;
import edu.uci.ics.crawler4j.url.WebURL;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;

public class NewsCrawler extends WebCrawler{

    private static final Pattern EXCLUDE_KEYWORD = Pattern.compile("^(?=.*(bmp|gif|jpg|png|css|php)).*$");
 
    @Override
    public boolean shouldVisit(Page referringPage, WebURL url) {

        String href = url.getURL().toLowerCase();

        // 直接のニュース記事に該当しないページは参照対象としない
        if (EXCLUDE_KEYWORD.matcher(href).matches() || 
            href.startsWith("https://jp.newsroom.ibm.com/announcements") ||
            !href.startsWith("https://jp.newsroom.ibm.com/")) {
            return false;
        }
        return true;
    }

    @Override
    public void visit(Page page) {

        if (page.getParseData() instanceof HtmlParseData) {
            HtmlParseData htmlParseData = (HtmlParseData) page.getParseData();

            HashMap<String, String> newsContents = new HashMap<String, String>();
            newsContents.put("url", page.getWebURL().getURL());

            String html = htmlParseData.getHtml();
            Document jsoupDoc = Jsoup.parse(html);
            String txt = jsoupDoc.select(".wd_news_body").text();
            newsContents.put("text", txt);
            // 有効と思われる結果のみ記録する
            if (txt.length() > 10) { 
                NewsCrawlConsumer.newsList.add(newsContents);
            }
        }
    }
}
```


##### Helloクラス
HelloWorldで使用したクラスを流用して、呼び出し先を変更します。
```java:Hello.java
    public String helloResponse() {
        //return "Hello World!!";
        return NewsCrawlConsumer.newsReq();
    }
```

##### 実行する
実行すると、IBMニュースルームの直近10件の要約が返ってきます。
結果は、実際に手元で実行して確認してみてください。

## 見えてきた課題
予定した機能は実装できましたが、様々な課題も見えてきました。
#### 処理に時間がかかる
ローカル環境で実行したところ、処理全体に3～4分くらいかかります。
クロール処理に1分、ChatAPIの応答時間が1件あたり8～20秒程度です。

クロールについては、実務で対応する際はバッチ処理で予めニュースを取得するという対策が考えられます。

要約については、APIの並列リクエストによる時間短縮が思い浮かびますが、APIの処理上限として「1分あたりのリクエスト数、トークン数」が決められており、こちらに抵触する可能性が高くなります。
今回のケースでは10件のデータを1リクエストにまとめるとトークン数が上限を超えてしまいます。
こちらもバッチ処理（処理待ちデータを一定ペースでAPI処理する）による対策が考えられます。

#### プロンプトが無視されることがある
「140文字以内で要約する」はsystemロール、userロールどちらで指定しても厳密には守られないようです。Bingチャットでも同じ傾向が見られます。
人間が要約しても140文字に収まらない内容だからなのかもしれません。

#### OpenAI側で処理できないケースがある
今回の検証では
400(Bad Request)
429(Too Many Requests)
の2パターンのエラーを確認しました。
今回テストしたタイミングでは処理対象に「[watsonxプラットフォームを発表](https://jp.newsroom.ibm.com/2023-05-09-IBM-Unveils-the-Watsonx-Platform-to-Power-Next-Generation-Foundation-Models-for-Business)」というニュースが含まれるのですが、これが常に400エラーとなりました。
テキスト量が多いという点が問題なのかもしれませんがAPI仕様によるものかまでは特定できていません。

429エラーは不定期に発生します。APIサーバー側の処理能力に依存する症状だとすると、リクエスト側からはNGケース時にリトライするという対処になりそうです。

#### 要約結果のレベルアップ
結果をみると一見よくまとまっていますが、今回想定したユースケースを考えるともう少しポイントを絞った要約が欲しいところです。
ここから先はプロンプト・エンジニアリングの領域になると考えます。

## まとめ
生成AI利用はJavaでも身近であるということが確認できました。
ただ実際に動かすことができると、今度はサービスとして提供するためには沢山のハードルがあるという気づきもありました。

自身の感想として、まずは「触って動かしてみる」ということが様々な理解につながるということを改めて認識しました。
Open Libertyを使用して、まずはスタート地点に立ちましょう！

## 参考
[Javaでクローラを実装する](https://qiita.com/petitviolet/items/e3b850fece36a176bf97)
[Crawler4jを利用した日本語コンテンツの収集](https://qiita.com/YasuhiroABE/items/4df272fcc0c2cccc4173)
[Sample Web Crawler using Crawler4j](https://medium.com/@csgsajeewa/sample-web-crawler-using-crawler4j-8033bfcd8e60)