🔥

ZennのRSSで流れてきた記事をGeminiでレビューしてSlackへ投稿する

2024/10/31に公開
2

クラスメソッドでは、DevelopersIO の記事を自動レビューする仕組みを導入しています。

https://dev.classmethod.jp/articles/devio-autoreview-by-bedrock/

執筆者視点でとても体験がよく、これをGoogle Cloud と Gemini で実装してみたいと思いました。Gemini版で Zenn の記事をレビューしてみます。結果として、RSS を登録し、記事を公開すると、以下のようなレビュー結果が届くようにできました。

文章のレビューに生成AIを活用する点は浸透してきているかと思います。本稿ではRSSを購読してSlackに投稿する流れを紹介しますので、Google Cloudでのインテグレーションのサンプルとして参考になれば幸いです。

構成

まず、全体の構成を以下のようにしました。

RSSフィード購読〜トピックへPublish

このうち、以下の赤枠の部分

こちらは、別記事をご覧ください。

https://zenn.dev/waddy/articles/cloud-run-functions-zenn-rss-to-pubsub

GeminiでレビューしてSlackへ投稿

本稿では上記赤枠部分について記載します。

項目 利用したもの
Cloud Run 関数のランタイム Node.js 20(TypeScript で書いてトランスパイル)
レビューで使う言語モデル gemini-1.5-pro-001

Cloud Pub/Sub に、未読コンテンツとして以下の形式のデータが流れてきた状況からスタートです。

{
  "title": "ZennのRSSで流れてきた記事をGeminiでレビューしてSlackへ投稿する",
  "url": "https://zenn.dev/waddy/articles/cloud-run-functions-gemini-review-to-slack"
}

このデータを用いて本文を取得し、レビューを実施します。最後に結果をSlackへ投稿します。以下のような順序で作業します。

  1. Slack App を用意する
  2. Cloud Run 関数を実装する
  3. 環境変数を用意してデプロイ

さっそく見ていきましょう。

Slack App を用意する

Cloud Run 関数からレビュー結果をpostMessageできるようにします。Slack App 経由で投稿するので、アプリを作成するところから行います。これについては、以下の記事で詳しく解説されていますので、記事の手順に従って設定を行ってください。

https://zenn.dev/kou_pg_0131/articles/slack-api-post-message

Slack チャンネルに API 経由でメッセージ送信できるところまで確認できれば OK です。

Cloud Run 関数を実装する

Cloud Pub/Sub からのメッセージを受け取るところから始まります。Cloud Run 関数では、以下の処理を実装します。

  • URLからHTMLをパースして本文を取得
  • メディアポリシーのプロンプトに沿ったレビューを依頼(Vertex AI API)
  • タイポのプロンプトに沿ったレビューを依頼(Vertex AI API)
  • 結果を Slack に投稿
index.ts
import { GoogleAuth } from "google-auth-library";
import fetch from "node-fetch";
import { WebClient } from "@slack/web-api";
import * as cheerio from "cheerio";
import { PubsubMessage } from "@google-cloud/pubsub/build/src/publisher/pubsub-message";
import { Firestore } from "@google-cloud/firestore";

const projectId = process.env.PROJECT_ID;
const location = process.env.LOCATION;
const modelId = process.env.MODEL_ID;

// Vertex AI API のエンドポイント URL
const vertexAiEndpoint = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${modelId}:generateContent`;

// Slackトークン(from Secret Manager)
const secretSlackToken = process.env.SLACK_TOKEN || "";

// 投稿するチャンネルID
const channelId = process.env.CHANNEL_ID || "";

// プロンプトを取得するためのFirestoreのコレクション名
// 本当はテキストファイルや固定文字列でもいいのですが、
// プロンプトを調整するたびにデプロイするのが億劫だったので、
// 執筆時点ではFirestoreから読み出すようにしています。
// プロンプトが読めれば保存場所はどこでもOKです
const firestore = new Firestore({
  databaseId: "rss-manager",
});

// Firestore のコレクション名
const collectionName = "prompts";

// Cloud Pub/Sub から送られてくるメッセージの型
type FeedItemType = {
  title: string;
  link: string;
  pubDate: string;
};
// Pub/Subのサブスクリション。届いたRSSのメタ情報をもとにGeminiでレビューする
export async function geminiReviewer(message: PubsubMessage) {
  const feedItemString = message.data
    ? Buffer.from(message.data as string, "base64").toString()
    : null;

  if (!feedItemString) {
    return;
  }

  const feedItem: FeedItemType = JSON.parse(feedItemString) as FeedItemType;
  const contentUrl = feedItem.link;
  const contentTitle = feedItem.title;
  const bodyHtml = await parseBodyHtml(contentUrl);

  // レビューリクエスト用のデータ
  const agenda = await getMediaPolicyPrompt(
    bodyHtml,
    "メディアポリシーに対する全体的な評価を行ってください"
  );
  const fix = await getMediaPolicyPrompt(
    bodyHtml,
    "メディアポリシーに対する問題箇所の指摘と改善案を出してください"
  );
  const good = await getMediaPolicyPrompt(
    bodyHtml,
    "メディアポリシーに沿った良い点を記載してください"
  );
  const [agendaBody, fixBody, goodBody] = [agenda, fix, good].map((prompt) => ({
    contents: {
      role: "user",
      parts: [
        {
          text: prompt,
        },
      ],
    },
    generation_config: {
      temperature: 0.7, // 生成の多様性を調整 (0.0 - 1.0)
      max_output_tokens: 1000, // 生成される最大トークン数
      top_p: 0.8, // トークン選択の多様性を調整
      top_k: 40, // 上位 K 個のトークンをサンプリング
    },
  }));

  const llmRequestTypographyBody = {
    contents: {
      role: "user",
      parts: [
        {
          text: await getTypographyPrompt(bodyHtml),
        },
      ],
    },
    generation_config: {
      temperature: 0.7, // 生成の多様性を調整 (0.0 - 1.0)
      max_output_tokens: 1000, // 生成される最大トークン数
      top_p: 0.8, // トークン選択の多様性を調整
      top_k: 40, // 上位 K 個のトークンをサンプリング
    },
  };

  // 認証トークンの取得
  const auth = new GoogleAuth({
    scopes: "https://www.googleapis.com/auth/cloud-platform",
  });
  const client = await auth.getClient();
  const accessToken = (await client.getAccessToken()).token || "";

  // Vertex AI にリクエストを送信
  const mediaPolicyAgenda = await fetchVertexAi(accessToken, agendaBody);
  const mediaPolicyFix = await fetchVertexAi(accessToken, fixBody);
  const mediaPolicyGood = await fetchVertexAi(accessToken, goodBody);
  const typo = await fetchVertexAi(accessToken, llmRequestTypographyBody);

  // Slack クライアントを初期化
  const slackClient = new WebClient(secretSlackToken);

  // Slack チャンネルにメッセージを投稿する
  await slackClient.chat.postMessage({
    channel: channelId,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `*レビューした記事*\n <${contentUrl}|${contentTitle}>`,
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `📚 * ${modelId}によるメディアポリシーのレビュー*`,
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*全体的な評価*",
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: mediaPolicyAgenda,
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*問題開所の指摘と改善案*",
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: mediaPolicyFix,
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*メディアポリシーに沿った良い点*",
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: mediaPolicyGood,
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `📝 * ${modelId}による誤字脱字チェック*`,
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: typo,
        },
      },
    ],
  });
  return;
}

長くなるためメイン処理のみ抜粋しました。次のようなことをやっています:

  • URL の中身をパースして本文を取得
  • メディアポリシーのプロンプトに沿ったレビューを依頼(Vertex AI API)
  • タイポのプロンプトに沿ったレビューを依頼(Vertex AI API)
  • 結果を Slack に投稿

プロンプトは適宜変更される可能性がありますが、参考までに執筆時点のものを掲載します。

メディアポリシー
あなたは企業ブログのレビュワーです

メディアポリシーに従ってブログ内に不適切な表現がないかチェックする必要があります。
メディアポリシーの内容をそのままレスポンスするのはやめてください。
メディアポリシーは以下の"~~~"で囲まれたMarkdownテキストです。

~~~
### ディス

* 「Aを褒めるためにBをけなす」含めNG
* 明らかな先方不備も含め、事実ベースの説明+以下で伝えるようにしてください
    * 手法的提案:ワークアラウンド紹介
    * 表現的提案:前向きな意見への言い換え(例:現在Windowsは非対応です。ユーザー的にも将来対応したてくれたらありがたいすよね)
* 人物を取り上げる際「自分は褒めているつもりだが相手は不快に」という可能性があるので注意

### ルール違反

* 引用の範囲を越えた転載
* 機密情報の暴露
    * シークレットキーらしき文字列
    * シークレット情報を公開してしまいそうな手順
* 法にもとる違反
    * 大麻取締法ほか:海外の体験でも日本法で裁かれます
    * 特商法、景表法:BtoC向けだが誤解を招くような数値の提示などはそもそも避ける
* そのほか具体的なNG項目
    * 個人情報、アカウントやIPなどの特定につながる情報
    * 他サイトに載せた内容をそのまま転載(双方の不利益になる)

### 憶測や伝聞

* ソース不足の情報**だけ**取り上げるのはNG。事実検証ならばOK
* 自分が知らないものを知らないまま書かない
    * 説明を外部サイトのリンク**のみ**で省略は極力避ける
    * 前提情報をどこまで書くかは「その記事の読者」目線で各自判断お願いします

### 誇張

* 特定製品が「世界一」などトップを自称する場合、先方にソース付きかチェックする。怪しい場合「トップクラス」など言い換えてください
* 社会的に関心の高いトピック等に対して取り扱う場合、まずは客観的な状況や背景を整理・共有しましょう。結果として持論が悪目立ちしたり最悪ディスとして誤解を招きます
* 自分を超えた「主語がデカい話」を事実や前提として語るケースも結果として実態とズレ、誇張になる可能性があるため要注意

### スラング・ミームの回避

テクニカルブログはナレッジを一人でも多くの人に届けることが目的です。短い期間でバズらせるための強い言葉選びや、多すぎる時事ネタは本題からの逸脱を招きます。以下のNG項目がないかチェックしてください。

* 特定のキャラクターが対話するような方式はNG
* 「脳死」「老害」「オワコン」「供養」などのネットスラングはNG

~~~
タイポ
あなたは企業ブログのレビュワーです

以下の観点でブログをレビューしてください。

- タイポが無いか?
- 日本語として不自然な言い回しが無いか?
- "て"、"に"、"お"、"は" など助詞の使い方に不自然な点が無いか?
- 最後に全体的な感想を述べてください

環境変数を用意してデプロイ

今回はいくつか環境変数やSecret Managerに置くものがありますね。

環境変数:

  • PROJECT_ID: VertexAI API エンドポイントのGoogle Cloudプロジェクト名です
  • LOCATION: VertexAI API エンドポイントのリージョン。asia-northeast1
  • MODEL_ID: VertexAI API エンドポイントで利用するモデル名。gemini-1.5-pro-001としました。
  • CHANNEL_ID: レビュー結果を投稿する先のSlack Channel ID

これらを.env.yamlに記載します。

.env.yaml(例)
PROJECT_ID: your-project-name
LOCATION: asia-northeast1
MODEL_ID: gemini-1.5-pro-001
CHANNEL_ID: SAMPLEJA23

Secret Manager:

先ほど作成したSlack AppのBot User OAuth Tokenを Secret Manager へ登録しましょう。値は、Slack App設定画面のInstalled App Settingsからコピーできます。

値を控えたら、Google Cloud 管理コンソールのSecret Managerから登録してください。シークレット名はSLACK_CHATOPS_BOT_TOKENとしました。

準備完了です。デプロイしましょう。

デプロイ

環境変数とSecretを設定するため、オプションを加えてデプロイします。

gcloud functions deploy geminiReviewer \
--region=asia-northeast1 \
--runtime=nodejs20 \
--memory=256 \
--timeout=30s \
--source=. \
--trigger-topic=rss-updates \
--project=your-project-name \
--entry-point=geminiReviewer \
--env-vars-file=.env.yaml \
--set-secrets SLACK_TOKEN=projects/999999999999/secrets/SLACK_CHATOPS_BOT_TOKEN:latest
  • --trigger-topic: コンテンツが流れてくる Cloud Pub/Subのトピック名を指定します
  • --env-vars-file: 作成した.env.yamlを指定してください
  • --set-secrets: シークレット名を指定します。これでCloud Run関数では環境変数としてシークレット値を取得できます

RSSを更新してみる

これでPub/SubへのPublish含め、すべてのコンポーネントがつながりました。ためしにZennに記事を書いて、RSSを更新してみましょう。無事にレビューが終わると、冒頭に載せた結果がSlackに投稿されます。

これで、5分ごとにRSSをチェックし、新着記事があれば内容をGeminiでレビューしてSlackへ投稿する仕組みができました。たくさん記事を書いて、どんどん自分の記事をレビューしてもらいたいですね。

おわりに

今回はZennの記事をレビューしてもらいましたが、本文を抽出するロジックさえ決まれば、どのブログサイトの記事でもレビューできます。このレビュー機構の気に入っているところと、もっとよくしたいポイントを述べておきます。

気に入っているところ

  • 公開されたコンテンツが対象なので、遠慮なく全文をレビューにかけられる
  • 自分の記事をメディアポリシーの観点でレビューしてもらう機会があまりないので、傾向やクセを再認識するきっかけになる
  • 「メディアポリシーに沿ったいいところを述べて」というプロンプトを入れているため、褒められる。嬉しい
  • 他の人の記事がどのようなレビューを受けるか、参考になる
  • 生成AIによるレビューなので、雑に見て、対応是非はこちらで決めてよく、返事もしなくよい。楽

もっとよくしたいポイント

  • RSSを使うため、新規投稿に対するレビューのみであり、修正したあとの更新に対する再レビューが自動ではできない
  • まれにHTMLタグ付きで引用してしまうことがあるため、入力はできればMarkdownがいいかもしれないが、いまのZennの仕組みだと難しい(公開コンテンツはHTMLだけ)
  • DevelopersIO では textlint の結果も入れているので、マネしたかったが、Node.jsで実行できなかった(調査中)

プロンプトの指定の仕方など、改善できそうなポイントがあればぜひ教えてください。

ソースコード

https://github.com/cm-wada-yusuke/llm-reviewer/tree/main/gemini-reviewer

Discussion

YAMAMOTO YujiYAMAMOTO Yuji

執筆ありがとうございます。

すごい細かいところなんですけど、「コピペ」っていうとコピーして貼り付けること全般を指しますし、「コピペや引用の範囲を越えた転載」ではなく単に「引用の範囲を超えた転載」でよいのではないでしょうか。

waddy_uwaddy_u

ありがとうございます。おっしゃるとおり、コピペは普段からしていますね…ご提案のとおり「引用の範囲を超えた転載」のみに修正しました。