180
117
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

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

Last updated at Posted at 2024-06-22

はじめに

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

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

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

安野たかひろ:都知事選マニフェストのGithub公開リポジトリ

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

変更まとめ

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

対応プルリクエストはこちら

ワークフローのタイムアウトが設定されました。

長時間ワークフローが実行されることを防ぐための修正です。
対応プルリクエストはこちら

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

PythonスクリプトがGithub Actions内に記載されていることが指摘されており、スクリプトの切り出しの対応を行った模様です。
対応プルリクエストはこちら

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

おことわり

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

.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
このワークフローは、新規作成(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

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

環境変数の設定

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. ラベルの作成(toxicduplicated)
    不適切なIssueに対してはtoxicラベルを、重複したIssueに対してはduplicatedラベルを作成
  4. Qdrantクライアント作成
    Qdrantに関してはこちらの記事を参照ください
    Qdrant 公式
    Python Qdrant Client リポジトリ
  5. OpenAIクライアントの作成
  6. Issue内テキストの内容を検証(Moderations)
    OpenAIが提供している潜在的に有害なテキストかどうかをチェックするAPIを使用 Moderationsについてはこちらを参照
  7. 画像を検証(GPT-4oに対し画像が暴力的か、性的な画像かをチェック)
    違反があれば、toxicラベルを追加し、警告コメントを投稿しIssueをクローズ
  8. Qdrantで検索する
    QdrantデータベースでIssueを検索
  9. 違反がなければIssueをQdrantデータベースに追加
  10. 重複している場合、Issueにdupulicatedラベルを追加
    Issueにduplicatedラベルを追加し、重複の可能性があるIssueへのリンクコメントを追加
    重複していなかったら0を返し、それ以外は重複IDを出力
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

このワークフローは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
ここから詳しく見ていきます。

環境変数の設定

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. 違反があればコメントを非表示にする
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が作成されたときにトリガーされるようになっています。

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

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

やっていることまとめ

  • Issueの本文を取得
  • Issueテンプレート内の政策ビジョンの一覧を取得
  • Issue内のカテゴリと作成されたIssueのカテゴリのマッチ
  • ラベルをIssueに追加
//  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)にプッシュされたことをトリガーにして、政策リポジトリにデプロイしています。

具体的にやっていること

  1. リポジトリのチェックアウト
  2. Gitのクレデンシャルを設定
  3. Pythonのセットアップ
  4. キャッシュIDの生成
  5. キャッシュの利用
  6. MkDocs Material
  7. MkDocsでのデプロイ
    MkDocsについて
    MkDocsのリポジトリ

おわりに

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

180
117
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
180
117