はじめに
こんにちは。
先日、エンジニア兼作家である安野たかひろさんが2024年の東京都知事選挙に出馬され、参加型のマニフェストを公開しました。
安野たかひろさんが何者かは、ご本人が記事を掲載しているので下記を参照ください。
エンジニア兼作家が東京都知事選挙の出馬表明記者会見をした会見全文
東京都知事選の「参加型マニフェスト」を公開します
タイトルの通り、マニフェストがGithubに公開され、誰でも変更提案のためにIssueを作れるようになっています。
ただ、公序良俗に反した内容や・誹謗中傷等の内容のチェックをする必要があり、それをGithub Actionsのワークフロー内で行っているので中身を見て解説していきます。
安野たかひろ:都知事選マニフェストのGithub公開リポジトリ
追記: 2024/06/29時点での最新状態に更新しました。以降は本記事の更新はしない予定です。
変更まとめ
新規イシューに政策カテゴリラベルを付与するGitHub Actionsを追加
ワークフローのタイムアウトが設定されました。
長時間ワークフローが実行されることを防ぐための修正です。
対応プルリクエストはこちら
🛠️ GitHub Issue 自動レビュー機能とコードの分離
PythonスクリプトがGithub Actions内に記載されていることが指摘されており、スクリプトの切り出しの対応を行った模様です。
対応プルリクエストはこちら
add-label.yml
とcomment-review.yml
はコードの分離対応はされていませんでした。
.github/workflowsの内部ツリー
全部で4つのワークフローがありました。
├── comment-review.yml # コメントレビュー
├── issue-review.yml #Issueレビュー
├── add-label.yml # Issueにラベルを追加
└── main.yml # ドキュメントのデプロイ
それぞれ中身を見ていきます。
Issue作成時レビュー(issue-review.yml)
ファイルはこちら issue-review.yml
このワークフローは、新規作成(opened)されたIssueをOpenAIのAPIを活用し、不適切な内容の検出レビューと重複チェックを行います。
ワークフローの流れ
- ワークフロー実行のために、Issueの書き込み権限
issues: write
とリポジトリ内容の読み取り権限contents: read
を許可 -
review_issue
という名のジョブをUbuntu環境で実行 - リポジトリのチェックアウト
- Python環境のセットアップ(3系)
- Pythonパッケージの依存関係のインストール
pipのアップグレード、openai、PyGithub、qdrant-client、regexパッケージをインストール - 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
にリファクタされ移動しましたがやっていることは変わっていないです。
やっていることまとめ
- ライブラリのインポート
- 環境変数の取得
- ラベルの作成(
toxic
とduplicated
)
不適切なIssueに対してはtoxic
ラベルを、重複したIssueに対してはduplicated
ラベルを作成 - Qdrantクライアント作成
Qdrantに関してはこちらの記事を参照ください
Qdrant 公式
Python Qdrant Client リポジトリ - OpenAIクライアントの作成
- Issue内テキストの内容を検証(Moderations)
OpenAIが提供している潜在的に有害なテキストかどうかをチェックするAPIを使用 Moderationsについてはこちらを参照 - 画像を検証(GPT-4oに対し画像が暴力的か、性的な画像かをチェック)
違反があれば、toxic
ラベルを追加し、警告コメントを投稿しIssueをクローズ - Qdrantで検索する
QdrantデータベースでIssueを検索 - 違反がなければIssueをQdrantデータベースに追加
- 重複している場合、Issueに
dupulicated
ラベルを追加
Issueにduplicated
ラベルを追加し、重複の可能性があるIssueへのリンクコメントを追加
重複していなかったら0を返し、それ以外は重複IDを出力
# 各種ライブラリのインポート
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を活用して不適切な内容検出を行い、それらのコメントを非表示にするためのものです。
ワークフローの流れ
- ワークフロー実行のために、Issueの書き込み権限
issues: write
とリポジトリ内容の読み取り権限contents: read
を許可 -
review_issue
という名のジョブをUbuntu環境で実行 - リポジトリのチェックアウト
- Python環境のセットアップ(3系)
- Pythonパッケージの依存関係のインストール
pipのアップグレード、openai、PyGithub、qdrant-client、regexパッケージをインストール - コメントのレビュー(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を叩いています。
やっていることまとめ
- ライブラリのインポート
- 環境変数からトークンを取得
- GitHubリポジトリとIssueを取得
- OpenAIクライアントを作成
- Issueのコメントを取得
- コメントの内容を検証
- 画像があれば画像を検証
- 違反があればコメントを非表示にする
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が作成されたときにトリガーされるようになっています。
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を使用してデプロイをしています。
特出した内容ではないので簡単にまとめます。
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)にプッシュされたことをトリガーにして、政策リポジトリにデプロイしています。
具体的にやっていること
- リポジトリのチェックアウト
- Gitのクレデンシャルを設定
- Pythonのセットアップ
- キャッシュIDの生成
- キャッシュの利用
- MkDocs Material
- MkDocsでのデプロイ
MkDocsについて
MkDocsのリポジトリ
おわりに
以上でワークフローの解説は終わりです。
いかがでしたでしょうか。
専用のモデルを構築しているのかなぁとも思ったんですが意外とシンプルな作りとなっていました。