【都知事選】マニフェストがGithubに公開されたので、Github Actionsのワークフローで何をしているのか解説する

# はじめに
こんにちは。
先日、エンジニア兼作家である安野たかひろさんが2024年の東京都知事選挙に出馬され、参加型のマニフェストを公開しました。

:::note info
安野たかひろさんが何者かは、ご本人が記事を掲載しているので下記を参照ください。
[エンジニア兼作家が東京都知事選挙の出馬表明記者会見をした会見全文](https://note.com/takahiroanno/n/nead5c2e2454b)
[東京都知事選の「参加型マニフェスト」を公開します](https://note.com/takahiroanno/n/n38a34f107867)
:::

タイトルの通り、マニフェストがGithubに公開され、誰でも変更提案のためにIssueを作れるようになっています。
ただ、公序良俗に反した内容や・誹謗中傷等の内容のチェックをする必要があり、それをGithub Actionsのワークフロー内で行っているので中身を見て解説していきます。

[安野たかひろ：都知事選マニフェストのGithub公開リポジトリ](https://github.com/takahiroanno2024/election2024)

:::note warn
追記: 2024/06/29時点での最新状態に更新しました。以降は本記事の更新はしない予定です。

### 変更まとめ
#### 新規イシューに政策カテゴリラベルを付与するGitHub Actionsを追加

[対応プルリクエストはこちら](https://github.com/takahiroanno2024/election2024/pull/79)

#### ワークフローのタイムアウトが設定されました。
長時間ワークフローが実行されることを防ぐための修正です。
[対応プルリクエストはこちら](https://github.com/takahiroanno2024/election2024/pull/90)

#### 🛠️ GitHub Issue 自動レビュー機能とコードの分離

PythonスクリプトがGithub Actions内に記載されていることが指摘されており、スクリプトの切り出しの対応を行った模様です。
[対応プルリクエストはこちら](https://github.com/takahiroanno2024/election2024/pull/113)

`add-label.yml`と`comment-review.yml`はコードの分離対応はされていませんでした。
:::


:::note warn
### おことわり
本記事に政治的意図は一切ありません。
あくまでワークフローに興味を持ったので本記事を執筆しています。
:::

# .github/workflowsの内部ツリー
全部で4つのワークフローがありました。

```.github/workflows
├── comment-review.yml # コメントレビュー
├── issue-review.yml #Issueレビュー
├── add-label.yml # Issueにラベルを追加
└── main.yml # ドキュメントのデプロイ

```
それぞれ中身を見ていきます。

# Issue作成時レビュー(issue-review.yml)


ファイルはこちら [issue-review.yml](https://github.com/takahiroanno2024/election2024/blob/main/.github/workflows/issue-review.yml)
このワークフローは、新規作成(opened)されたIssueをOpenAIのAPIを活用し、不適切な内容の検出レビューと重複チェックを行います。

## ワークフローの流れ
1. ワークフロー実行のために、Issueの書き込み権限`issues: write`とリポジトリ内容の読み取り権限 `contents: read` を許可
2. `review_issue`という名のジョブをUbuntu環境で実行
3. リポジトリのチェックアウト
4. Python環境のセットアップ(3系)
5. Pythonパッケージの依存関係のインストール
  pipのアップグレード、openai、PyGithub、qdrant-client、regexパッケージをインストール
6. Issueのレビュー(Python環境でOpenAIのAPIを実行・評価)


## OpenAIのAPIを用いたIssueのレビュー
`name: Review comment with LLM`

ここから詳しく見ていきます。

**環境変数の設定** 

```yml
env:
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  QD_API_KEY: ${{ secrets.QD_API_KEY }}
  QD_URL: ${{ secrets.QD_URL }}
```


**Pythonスクリプト**
テキストおよび画像検証のためにスクリプトを実行し、OpenAIのAPIを叩いています。
こちらはPythonスクリプトは`scripts/review_issue.py`にリファクタされ移動しましたがやっていることは変わっていないです。

やっていることまとめ
1. ライブラリのインポート
2. 環境変数の取得
3. ラベルの作成(`toxic`と`duplicated`)
   不適切なIssueに対しては`toxic`ラベルを、重複したIssueに対しては`duplicated`ラベルを作成
4. Qdrantクライアント作成
  Qdrantに関しては[こちらの記事を参照ください](https://zenn.dev/tfutada/articles/acf8adbb2ba5be)
  [Qdrant 公式](https://qdrant.tech/)
  [Python Qdrant Client リポジトリ](https://github.com/qdrant/qdrant-client)
5. OpenAIクライアントの作成
6. Issue内テキストの内容を検証(Moderations)
   OpenAIが提供している潜在的に有害なテキストかどうかをチェックするAPIを使用 [Moderationsについてはこちらを参照](https://platform.openai.com/docs/api-reference/moderations)
7. 画像を検証(GPT-4oに対し画像が暴力的か、性的な画像かをチェック)
   違反があれば、`toxic`ラベルを追加し、警告コメントを投稿しIssueをクローズ
8. Qdrantで検索する
   QdrantデータベースでIssueを検索
10. 違反がなければIssueをQdrantデータベースに追加
11. 重複している場合、Issueに`dupulicated`ラベルを追加
   Issueに`duplicated`ラベルを追加し、重複の可能性があるIssueへのリンクコメントを追加
   重複していなかったら0を返し、それ以外は重複IDを出力

```python:scripts/review_issue.py
# 各種ライブラリのインポート
import os
from typing import List, Dict, Any
import regex as re
from github import Github
from github.Issue import Issue
from github.Repository import Repository
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
import openai

# GitHub Actions環境で実行されていない場合のみ.envファイルを読み込む
if not os.getenv('GITHUB_ACTIONS'):
    from dotenv import load_dotenv
    load_dotenv()

# 定数
EMBEDDING_MODEL = "text-embedding-3-small"
COLLECTION_NAME = "issue_collection"
GPT_MODEL = "gpt-4o"
MAX_RESULTS = 3

class Config:
    # 環境変数のセット
    def __init__(self):
        print("設定の初期化を開始します...")
        self.github_token = os.getenv("GITHUB_TOKEN")
        if self.github_token is None:
            print("GITHUB_TOKENが見つかりません ...")
        else:
            print("GITHUB_TOKENからトークンを正常に取得しました。")
        
        self.qd_api_key = os.getenv("QD_API_KEY")
        print("QD_API_KEYの状態:", "取得済み" if self.qd_api_key else "見つかりません")
        
        self.qd_url = os.getenv("QD_URL")
        print("QD_URLの状態:", "取得済み" if self.qd_url else "見つかりません")
        
        self.github_repo = os.getenv("GITHUB_REPOSITORY")
        print("GITHUB_REPOSITORYの状態:", "取得済み" if self.github_repo else "見つかりません")
        
        self.issue_number = os.getenv("GITHUB_EVENT_ISSUE_NUMBER")
        if self.issue_number:
            self.issue_number = int(self.issue_number)
            print(f"GITHUB_EVENT_ISSUE_NUMBER: {self.issue_number}")
        else:
            print("GITHUB_EVENT_ISSUE_NUMBERが見つかりません")
        print("設定の初期化が完了しました。")

# GitHubの操作クラス
class GithubHandler:
    def __init__(self, config: Config):
        self.github = Github(config.github_token)
        self.repo = self.github.get_repo(config.github_repo)
        self.issue = self.repo.get_issue(config.issue_number)

    def create_labels(self):
        """ラベルを作成する（既に存在する場合は無視）"""
        try:
            self.repo.create_label(name="toxic", color="ff0000")
            self.repo.create_label(name="duplicated", color="708090")
        except:
            pass

    def add_label(self, label: str):
        """Issueにラベルを追加する"""
        self.issue.add_to_labels(label)

    def close_issue(self):
        """Issueをクローズする"""
        self.issue.edit(state="closed")

    def add_comment(self, comment: str):
        """Issueにコメントを追加する"""
        self.issue.create_comment(comment)

# 作成されたIssue内に不適切な要素がないかチェックするクラス
class ContentModerator:
    def __init__(self, openai_client: openai.Client):
        self.openai_client = openai_client

    def validate_image(self, text: str) -> bool:
        """画像の内容が不適切かどうかを判断する"""
        image_url = self._extract_image_url(text)
        if not image_url:
            return False

        prompt = "この画像が暴力的、もしくは性的な画像の場合trueと返してください。"
        try:
            response = self.openai_client.chat.completions.create(
                model=GPT_MODEL,
                messages=[
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": prompt},
                            {"type": "image_url", "image_url": {"url": image_url}},
                        ],
                    }
                ],
                max_tokens=1200,
            )
            return "true" in response.choices[0].message.content.lower()
        except:
            return True

    def judge_violation(self, text: str) -> bool:
        """テキストと画像の内容が不適切かどうかを判断する"""
        response = self.openai_client.moderations.create(input=text)
        return response.results[0].flagged or self.validate_image(text)

    @staticmethod
    def _extract_image_url(text: str) -> str:
        """テキストから画像URLを抽出する"""
        match = re.search(r"!\[[^\s]+\]\((https://[^\s]+)\)", text)
        return match[1] if match and len(match) > 1 else ""

# Qdrantの操作クラス
class QdrantHandler:
    def __init__(self, client: QdrantClient, openai_client: openai.Client):
        self.client = client
        self.openai_client = openai_client

    def add_issue(self, text: str, issue_number: int):
        """新しい問題をQdrantに追加する"""
        embedding = self._create_embedding(text)
        point = PointStruct(id=issue_number, vector=embedding, payload={"text": text})
        self.client.upsert(COLLECTION_NAME, [point])

    def search_similar_issues(self, text: str) -> List[Dict[str, Any]]:
        """類似の問題を検索する"""
        embedding = self._create_embedding(text)
        results = self.client.search(collection_name=COLLECTION_NAME, query_vector=embedding)
        return results[:MAX_RESULTS]

    def _create_embedding(self, text: str) -> List[float]:
        """テキストのembeddingを作成する"""
        result = self.openai_client.embeddings.create(input=[text], model=EMBEDDING_MODEL)
        return result.data[0].embedding

class IssueProcessor:
    def __init__(self, github_handler: GithubHandler, content_moderator: ContentModerator, qdrant_handler: QdrantHandler, openai_client: openai.Client):
        self.github_handler = github_handler
        self.content_moderator = content_moderator
        self.qdrant_handler = qdrant_handler
        self.openai_client = openai_client

    # Issueをチェックし、不適切な画像・文章がないかの検閲、重複しているかをチェック
    def process_issue(self, issue_content: str):
        """Issueを処理する"""
        if self.content_moderator.judge_violation(issue_content):
            self._handle_violation()
            return

        similar_issues = self.qdrant_handler.search_similar_issues(issue_content)
        if not similar_issues:
            self.qdrant_handler.add_issue(issue_content, self.github_handler.issue.number)
            return

        duplicate_id = self._check_duplication(issue_content, similar_issues)
        if duplicate_id:
            self._handle_duplication(duplicate_id)
        else:
            self.qdrant_handler.add_issue(issue_content, self.github_handler.issue.number)

    def _handle_violation(self):
        """違反を処理する"""
        self.github_handler.add_label("toxic")
        self.github_handler.add_comment("不適切な投稿です。アカウントBANの危険性があります。")
        self.github_handler.close_issue()

    def _check_duplication(self, issue_content: str, similar_issues: List[Dict[str, Any]]) -> int:
        """重複をチェックする"""
        prompt = self._create_duplication_check_prompt(issue_content, similar_issues)
        completion = self.openai_client.chat.completions.create(
            model=GPT_MODEL,
            max_tokens=1024,
            messages=[{"role": "system", "content": prompt}]
        )
        review = completion.choices[0].message.content
        if ":" in review:
            review = review.split(":")[-1]
        return int(review) if review.isdecimal() and review != "0" else 0

    def _handle_duplication(self, duplicate_id: int):
        """重複を処理する"""
        self.github_handler.add_label("duplicated")
        self.github_handler.add_comment(f"#{duplicate_id} と重複しているかもしれません")

    @staticmethod
    def _create_duplication_check_prompt(issue_content: str, similar_issues: List[Dict[str, Any]]) -> str:
        """重複チェック用のプロンプトを作成する"""
        similar_issues_text = "\n".join([f'id:{issue.id}\n内容:{issue.payload["text"]}' for issue in similar_issues])
        return f"""
        以下は市民から寄せられた政策提案です。
        {issue_content}
        この投稿を読み、以下の過去提案の中に重複する提案があるかを判断してください。
        {similar_issues_text}
        重複する提案があればそのidを出力してください。
        もし存在しない場合は0と出力してください。

        [出力形式]
        id:0
        """

def setup():
    """セットアップを行い、必要なオブジェクトを返す"""
    config = Config()
    github_handler = GithubHandler(config)
    github_handler.create_labels()

    openai_client = openai.Client()
    content_moderator = ContentModerator(openai_client)

    qdrant_client = QdrantClient(url=config.qd_url, api_key=config.qd_api_key)
    qdrant_handler = QdrantHandler(qdrant_client, openai_client)

    return github_handler, content_moderator, qdrant_handler, openai_client

def main():
    github_handler, content_moderator, qdrant_handler, openai_client = setup()
    issue_processor = IssueProcessor(github_handler, content_moderator, qdrant_handler, openai_client)
    issue_content = f"{github_handler.issue.title}\n{github_handler.issue.body}"
    issue_processor.process_issue(issue_content)

if __name__ == "__main__":
    main()
```
1ファイルにまとめられているため、長くなっていますが、各種クラスが作成され可読性が上がりました。
また、コメントも適宜付与されています。


# コメントレビュー(comment-reveiw.yml)

こちらもIssue作成時と同じようなワークフローでした。
ファイルはこちら [comment-review.yml](https://github.com/takahiroanno2024/election2024/blob/main/.github/workflows/comment-review.yml)

このワークフローはIssueのコメントをレビューし、OpenAIのAPIを活用して不適切な内容検出を行い、それらのコメントを非表示にするためのものです。

## ワークフローの流れ
1. ワークフロー実行のために、Issueの書き込み権限`issues: write`とリポジトリ内容の読み取り権限 `contents: read` を許可
2. `review_issue`という名のジョブをUbuntu環境で実行
3. リポジトリのチェックアウト
4. Python環境のセットアップ(3系)
5. Pythonパッケージの依存関係のインストール
  pipのアップグレード、openai、PyGithub、qdrant-client、regexパッケージをインストール
6. コメントのレビュー(Python環境でOpenAIのAPIを実行・評価)

## OpenAIのAPIを用いたコメントのレビュー
`name: Review comment with LLM`
ここから詳しく見ていきます。

**環境変数の設定**
```yml
env:
  OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  QD_API_KEY: ${{ secrets.QD_API_KEY }}
  QD_URL: ${{ secrets.QD_URL }}
```
環境変数として、OpenAI APIキー、GitHubトークン、Qdrant APIキー、Qdrant URLを設定。


**Pythonスクリプト**
テキストおよび画像検証のためにスクリプトを実行し、OpenAIのAPIを叩いています。

やっていることまとめ
1. ライブラリのインポート
2. 環境変数からトークンを取得
3. GitHubリポジトリとIssueを取得
4. OpenAIクライアントを作成
5. Issueのコメントを取得 
6. コメントの内容を検証
7. 画像があれば画像を検証
8. 違反があればコメントを非表示にする


```python
import github
from github import Github
import os
import openai
import regex as re

token = os.getenv("GITHUB_TOKEN")
qd_api = os.getenv("QD_API_KEY")
qd_url = os.getenv("QD_URL")
g = Github(token)
repo = g.get_repo("${{ github.repository }}")
issue = repo.get_issue(${{ github.event.issue.number }})
issue_content = f"{issue.title}\n{issue.body}"
openai_client = openai.Client()
embedding_model = "text-embedding-3-small"
collection_name = "issue_collection"

# 画像の検証を行う関数
def validate_image(text):
    model_name = "gpt-4o"
    prompt = "この画像が暴力的、もしくは性的な画像の場合trueと返してください。"
    image_url = re.search(r"!\[[^\s]+\]\((https://[^\s]+)\)", text)
    if image_url and len(image_url) > 1:
        image_url = image_url[1]
    else:
        return False
    try:
        response = openai_client.chat.completions.create(
            model=model_name,
            messages=[
            {
                "role": "user",
                "content": [
                {"type": "text", "text": prompt},
                {
                    "type": "image_url",
                    "image_url": {
                    "url": image_url
                    },
                },
                ],
            }
            ],
            max_tokens=1200,
        )
    except:
        return True
    v = response.choices[0].message.content.lower()
    if "true" in v:
        return True
    else:
        return False

# コメントの内容を検証する関数
def judge_violation(text):
    response = openai_client.moderations.create(input=text)
    print(response)
    flag = response.results[0].flagged
    video_flag = validate_image(text)
    if flag or video_flag:
        return True
    return flag
comments = issue.get_comments()

for comment in comments:
    if judge_violation(comment.body):
        comment.edit("このコメントは非表示にされました")

```

# Issueにラベルを追加(add-label.yml)
こちらは新たに作成されたワークフローです。
Issueのレビューが完了し、無事にIssueが作成されたときにトリガーされるようになっています。

```yml:add-label.yml
on:
  issues:
    types: [opened]
```

**GitHubスクリプト**
2つのレビューのワークフローと同様にスクリプトが実行されていますが、`actions/github-script@7`を使用しています。
これはインラインの`javascript`になります。


やっていることまとめ
- Issueの本文を取得
- Issueテンプレート内の政策ビジョンの一覧を取得
- Issue内のカテゴリと作成されたIssueのカテゴリのマッチ
- ラベルをIssueに追加

```javascript
//  issueの本文を取得
const issueBody = context.payload.issue.body;
const labelsToAdd = [];

//  政策ビジョンセクションを抽出
const policyVisionSectionMatch = issueBody.match(/## 政策ビジョン([\s\S]*)## 政策/);
const policyVisionSection = policyVisionSectionMatch ? policyVisionSectionMatch[1] : '';

// カテゴリと対応するラベルの設定
const categories = [
    { regex: /^経済$/, label: '経済' },
    { regex: /^医療・防災$/, label: '医療・防災' },
    { regex: /^教育・子育て$/, label: '教育・子育て' },
    { regex: /^行政$/, label: '行政' },
    { regex: /^民主主義$/, label: '民主主義' },
    { regex: /^その他$/, label: 'その他' },
];

// セクション内の各行を解析し、対応するラベルを追加
policyVisionSection.split('\n').forEach(line => {
    line.split(', ').forEach(item => {
        categories.forEach(category => {
        if (category.regex.test(item)) {
            console.log(`Match: ${category.label}`);
            labelsToAdd.push(category.label);
        }
        });
    });
});

// ラベルをissueに追加
if (labelsToAdd.length > 0) {
    github.rest.issues.addLabels({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.issue.number,
        labels: labelsToAdd
    });
}
```


# デプロイ(main.yml)

こちらのワークフローでは、MkDocsを使用してデプロイをしています。
特出した内容ではないので簡単にまとめます。

```main.yml
name: mainci
on:
  push:
    branches:
      - master 
      - main
permissions:
  contents: write
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 1
    steps:
      - uses: actions/checkout@v4
      - name: Configure Git Credentials
        run: |
          git config user.name github-actions[bot]
          git config user.email 41898282+github-actions[bot]@users.noreply.github.com
      - uses: actions/setup-python@v5
        with:
          python-version: 3.x
      - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 
      - uses: actions/cache@v4
        with:
          key: mkdocs-material-${{ env.cache_id }}
          path: .cache
          restore-keys: |
            mkdocs-material-
      - run: pip install mkdocs-material 
      - run: mkdocs gh-deploy --force
```

main.ymlはmain(or master)にプッシュされたことをトリガーにして、[政策リポジトリ](https://manifest.takahiroanno.com/)にデプロイしています。

具体的にやっていること
1. リポジトリのチェックアウト
2. Gitのクレデンシャルを設定
3. Pythonのセットアップ
4. キャッシュIDの生成
5. キャッシュの利用
6. MkDocs Material
7. MkDocsでのデプロイ
  [MkDocsについて](https://squidfunk.github.io/mkdocs-material/)
  [MkDocsのリポジトリ](https://github.com/squidfunk/mkdocs-material)



# おわりに
以上でワークフローの解説は終わりです。
いかがでしたでしょうか。
専用のモデルを構築しているのかなぁとも思ったんですが意外とシンプルな作りとなっていました。