はじめに
個人開発のダッシュボードアプリで、Todoの管理をGitHub Issueでやっています。各Issueには「変更の種類」「対応優先度」「変更が必要な対象(バックエンド、フロントエンドなど)領域」の3観点でラベルを付けています。変更の種類はIssueテンプレートが自動で付けてくれますが、優先度と領域は起票のたびに手で選ぶ必要があり、付け忘れたIssueが溜まっていました。
そこで、Issueが起票されたら、GitHub ActionsからJevに本文を渡して優先度と領域を判定させ、ラベルを付ける仕組みを作りました。
Jev(TypeSafe)とは
TypeSafeが提供している、System Oneと呼ばれる種類のモデルです。すごく雑に言うと、「判断のみを行うモデル」です。ChatGPTやClaudeのように文章を生成することはなく、答えの型は次の3種類に限られます。
| 質問の型 | 返ってくるもの | 向いている用途 |
|---|---|---|
| Choice | 選択肢それぞれの確率と、最も確率の高い選択肢 | 決まった選択肢から1つ選ぶ分類。問い合わせをどのチームに回すか、記事はどのカテゴリか、など |
| Noul | 「はい」である確率(0〜1) | 条件に当てはまるかの判定。緊急か、規約違反を含むか、など。複数のラベルが同時に付きうる分類は、ラベルごとにNoulを1問ずつ立てる |
| Score | 順序つきの段階に対する確率と期待値 | 程度の評価。不具合の深刻度、顧客の怒りの強さ、など |
なおJev登場後、各社から判断を行うモデルが次々と登場しています。OpenAIからは「Decisions API」、Microsoftからは「Microsoft-Decision-1」、Strands Labsからは「Strands Decider 2B」など多くのモデルがあります。各モデル仕様は違えど、LLMと比べると安価で高速な応答が返ってくる点が特徴です。
検証環境
今回の検証環境です。
| 項目 | 値 |
|---|---|
| モデル | jev-1.13 |
| Python SDK | typesafe-sdk 0.7.1(Python 3.10以上) |
| 実行環境 | GitHub Actions ubuntu-latest、Python 3.13 |
運用ルール
運用ルールとして決めたのは次の2つです。
- すでに付いているラベルは触らないこと
- 確信が持てない判定は付けないこと
- 優先度はconfidenceが0.5未満、領域はyesの確率が0.5未満ならラベルを付けない
質問は英語で書く
公式ドキュメントによると、Jevは英語を主な言語として学習しており、精度が最も高いのも英語です。日本語を含むCJK(中国語・日本語・韓国語)の文章も受け付けますが、英語ほどの精度は出ないと書かれています。
そこで、質問文と選択肢の説明はすべて英語で書いています。ラベルの説明文も、リポジトリにある日本語の定義を英訳して使っています。Issueのタイトルと本文は日本語のまま送っています。
精度について
これまで自分でラベルを付けてきた既存のIssueを正解データにして、質問文を変えながら精度を測りました。
領域は「関係するか」ではなく「主な作業対象か」と聞く
最初は、領域ごとに「この領域は関係するか」と聞いていました。この方式だと、例えばフロントエンドの画面に出るエラーの話なら、原因がバックエンドでもフロントエンドにyesが付く、という具合に、言及されただけの領域まで対象としてしまいます。
質問を「この領域が、このIssueを解決するために変更が必要な主な領域の1つか」に変え、noの条件として「言及されているだけ、間接的に影響するだけ、問題が起きている場所というだけなら該当しない」と書き添えたところ、精度が向上しました。Jevは指示を字義どおりに読むので、どこまでを該当とするかを文章で書いておけば、そのとおりに判定してくれます。
Noul(
instructions=f"Is {description} one of the main areas that must be changed to resolve this issue?",
criteria={
"true": "Substantial work in this area is required. A person doing this work would edit files or configuration belonging to this area.",
"false": "This area is only mentioned, only indirectly affected, or merely the context in which the problem appears. No substantial work happens here.",
},
)
優先度はScoreよりChoiceのほうが当たった
4パターンで検証した結果、優先度はScoreよりもChoiceのほうが一致率が高くなりました。
| 方式 | confidenceの足切り | 付与率 | 正解と一致 | ±1段階以内 |
|---|---|---|---|---|
| Choice | なし | 100% | 67% | 95% |
| Choice | 0.5以上 | 81% | 73% | 95% |
| Choice | 0.7以上 | 59% | 76% | 98% |
| Score | 0.5以上 | 92% | 56% | 94% |
Scoreは期待値が段階の間に落ちるので、値の取り扱い方について決める必要があります。この方式では四捨五入(例えば2.5なら3として扱う)して判断しました。
Choiceは選択肢ごとの確率がそのまま出るので、最も確率の高いものを採るだけで済みました。誤りはmediumをhighと判定する上振れが中心で、±1段階以内なら95%が収まっています。今回はラベルの付与率を考慮して0.5以上のconfidenceでラベルを付与するChoiceを採用しました。
質問文は「How urgently should this issue be addressed?」と聞き、選択肢の説明にはラベルの定義文をそのまま英訳したものを使っています。
Choice(
instructions="How urgently should this issue be addressed?",
criteria={
"critical": "Production outage, serious vulnerability, or data corruption. Needs immediate action.",
"high": "Likely to cause a failure or operational stoppage in the near future. Needs early attention.",
"medium": "Design debt, operational quality improvement, or an ordinary feature addition. Handled as planned work.",
"low": "Low urgency. An improvement, cleanup, or small refactor to do when there is spare time.",
},
)
GitHub Actionsに組み込む
ワークフロー
以下のようなGitHub Actions用ymlファイルを作成しました。
name: Issue Triage
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
issue_number:
description: 分類する issue 番号
required: true
permissions:
contents: read
issues: write
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
cache: pip
cache-dependency-path: scripts/requirements.txt
- run: pip install -r scripts/requirements.txt
- env:
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
GH_TOKEN: ${{ github.token }}
ISSUE_NUMBER: ${{ github.event.issue.number || inputs.issue_number }}
run: python scripts/issue_triage.py "$ISSUE_NUMBER"
スクリプトに渡すのはIssue番号だけで、本文はスクリプト側で gh issue view を使って取ります。
requirements.txt の中身は typesafe-sdk~=0.7.1 の1行です。APIキーはリポジトリのSecretsに TYPESAFE_API_KEY として登録しておきます。
uses: のバージョンをタグではなくコミットのSHAで固定しているのは、タグの指し先を書き換えられるサプライチェーン攻撃への対策です。動作には関係ないので、actions/checkout@v7 のようにタグで書いても動きます。
スクリプト
処理は上から順に、Issueの取得、質問の組み立て、Jevへの問い合わせ、しきい値での判定、ラベル付与です。
import json
import os
import subprocess
import sys
from typesafe_sdk import Choice, Noul, TypeSafeAPIError, TypeSafeClient
PRIORITIES = {
"critical": "Production outage, serious vulnerability, or data corruption. Needs immediate action.",
"high": "Likely to cause a failure or operational stoppage in the near future. Needs early attention.",
"medium": "Design debt, operational quality improvement, or an ordinary feature addition. Handled as planned work.",
"low": "Low urgency. An improvement, cleanup, or small refactor to do when there is spare time.",
}
PRIORITY_CONFIDENCE = 0.5
AREAS = {
"frontend": "React, TypeScript, routing, screens, CSS, or mobile display",
"backend": "Lambda, AppSync, or server-side data collection processing",
"ai": "Bedrock, AgentCore, LLM, AI chat, AI summarization, or AI-based recommendation scoring",
"database": "DynamoDB, data models, schema, TTL, data retention, or authorization rules",
"devops": "GitHub Actions, Amplify, CDK, CI/CD, deployment, development environment, or operational procedures",
"security": "authentication, authorization, IAM, secrets, untrusted external input, or CSP",
"cost": "AWS spending, resource retention periods, retries, or sampling, as a cost concern",
"observability": "logs, metrics, traces, alarms, or notifications",
"performance": "response speed, pagination, caching, bundle size, or parallelization",
"accessibility": "ARIA, keyboard operation, screen readers, focus, or contrast",
"test": "test code, test infrastructure, or coverage, as the main purpose of the work",
}
AREA_THRESHOLD = 0.5
def main() -> None:
args = sys.argv[1:]
if not args:
raise SystemExit("issue 番号を引数で指定してください")
issue_number, dry_run = args[0], "--dry-run" in args
api_key = os.environ.get("TYPESAFE_API_KEY")
if not api_key:
raise SystemExit("TYPESAFE_API_KEY が設定されていません")
# 1. issue の本文と既存ラベルを取得する
issue = json.loads(
subprocess.run(
["gh", "issue", "view", issue_number, "--json", "title,body,labels"],
check=True,
capture_output=True,
text=True,
).stdout
)
existing_labels = [label["name"] for label in issue["labels"]]
print(f"#{issue_number} {issue['title']}")
print(f" 既存ラベル: {', '.join(existing_labels) or '(なし)'}")
# 2. 足りない観点だけ質問を組み立てる(優先度は 4 択、領域はラベルごとに yes/no)
questions: dict[str, Choice | Noul] = {}
if not any(label.startswith("priority:") for label in existing_labels):
questions["priority"] = Choice(
instructions="How urgently should this issue be addressed?",
criteria=PRIORITIES,
)
if not any(label.startswith("area:") for label in existing_labels):
for name, description in AREAS.items():
questions[f"area_{name}"] = Noul(
instructions=f"Is {description} one of the main areas that must be changed to resolve this issue?",
criteria={
"true": "Substantial work in this area is required. A person doing this work would edit files or configuration belonging to this area.",
"false": "This area is only mentioned, only indirectly affected, or merely the context in which the problem appears. No substantial work happens here.",
},
)
if not questions:
print(" 付与済みのため判定しない")
return
# 3. Jev に 1 リクエストでまとめて問い合わせる
try:
with TypeSafeClient(api_key=api_key) as client:
response = client.system_one({"title": issue["title"], "body": issue["body"] or ""}, questions)
except TypeSafeAPIError as error:
# 本文に curl コマンドなどがあると Cloudflare WAF に弾かれる。再試行しても通らないので判定なしとする
if (
error.status == 403
and "text/html" in (error.headers.get("content-type") or "")
and "cloudflare" in str(error.body).lower()
):
print(" WAF に弾かれたため判定をスキップ")
return
raise
# 4. 確信の持てる判定だけラベルにする
labels = []
if "priority" in response.choices:
answer = response.choices["priority"]
print(f" 優先度: {answer.choice} (confidence {answer.confidence:.2f})")
if answer.confidence >= PRIORITY_CONFIDENCE:
labels.append(f"priority:{answer.choice}")
areas = sorted(((k.removeprefix("area_"), a.noul) for k, a in response.nouls.items()), key=lambda x: -x[1])
if areas:
print(f" 領域: {' '.join(f'{name}={noul:.2f}' for name, noul in areas)}")
labels += [f"area:{name}" for name, noul in areas if noul >= AREA_THRESHOLD]
# 5. ラベルを付ける
print(f" 付けるラベル: {', '.join(labels) or '(なし)'}{'(dry-run)' if dry_run else ''}")
if labels and not dry_run:
subprocess.run(["gh", "issue", "edit", issue_number, "--add-label", ",".join(labels)], check=True)
if __name__ == "__main__":
main()
WAFの403を握りつぶしている部分の補足です。TypeSafeのAPIの手前にはCloudflareのWAFがあり、リクエスト本文に攻撃らしいパターンがあると403で遮断します。curl https://... のようなコマンドは、サーバーに外部のURLへアクセスさせるコマンドインジェクションの典型的な形なので、セキュリティ対策として弾かれていると考えています。
SDKの使い方はシンプルで、TypeSafeClient を作って system_one(state, questions) を呼ぶだけです。stateには判定してほしい内容(ここではIssueのタイトルと本文)を渡します。答えは response.choices["priority"] や response.nouls["area_backend"] のように確認します。
手元で試すときは --dry-run を付けると、判定結果を表示するだけでラベルは付けません。
TYPESAFE_API_KEY=... GH_REPO=owner/repo python3 scripts/issue_triage.py 647 --dry-run
まとめ
「Issueの本文を読んでラベルを付ける」という仕事は、文章を生成する必要がまったくないので、確率だけを返すモデルのほうが扱いやすいと感じました。料金は入力100万トークンあたり0.042ドルで、出力は無料(2026年10月時点)と、コストが安い点も魅力です。今回のように自動でラベルを付けるケースでは活きませんが、レスポンスが早い点も良いです。
参考