チケット管理システムのissueを受け取って、対象リポジトリを調査し、GitHubにDraft PRを自動作成するシステムを作っている。
AIにコードを書かせる部分の設計でわかったことを残しておく。
ファイルを全部渡すとコストが爆発する
最初の実装は「チケットとファイルを全部渡してコードを生成させる」だった。動くけど、ファイルが増えると入力トークンが爆発する。関係ないファイルを大量に渡しても精度は上がらなかった。
なので2ステップに分けた。
Step 1: ファイルの「中身」は渡さず「パス一覧」だけを渡して、チケットに関係しそうなファイルを絞り込む。
Step 2: 絞り込んだファイルの中身だけを渡して、修正コードを生成する。
ファイルパスを見るだけでも「このバグはroutes/auth.pyとmodels/user.pyあたりだろう」という絞り込みはかなりできる。
async def identify_relevant_files(self, issue: dict, file_tree: list[str]) -> list[str]:
tree_text = "\n".join(file_tree)
prompt = f"""## チケット
Summary: {issue.get('summary')}
Description:
{issue.get('description', '(no description)')}
## リポジトリのファイルツリー
{tree_text}
---
このチケットに対応するために必要なファイルを特定してください。
Return JSON only:
{{"files": ["path/to/file1.py", "path/to/file2.py"]}}
Rules:
- チケットに記載された内容に直接関係するファイルのみ選ぶ
- 新規ファイルが必要な場合は想定されるパスを含めてよい
- 最大15ファイル、関連度の高い順
"""
response = await client.messages.create(
model=MODEL, max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
return self._parse_file_list(response.content[0].text)
「チケットに直接関係するファイルのみ」と明示しているのがポイント。これがないと「関係するかも」なファイルをどんどん追加してくる。max_tokens=512に絞っているのも、パスのリストだけ返せばいいので長い回答を許さないため。
修正コードをcommit_groups形式で返させる
Step 2ではファイル内容を渡して修正コードを生成する。このとき差分をcommit_groupsという形式で返させている。
{
"is_fixable": true,
"reasoning": "〇〇が原因のため△△を修正する",
"commit_groups": [
{
"message": "feat: 注文履歴機能を追加",
"files": [
{ "path": "order_history.py", "content": "変更後の完全なファイル内容" }
]
},
{
"message": "test: 注文履歴のテストを追加",
"files": [
{ "path": "test_order_history.py", "content": "テストコードの内容" }
]
}
],
"pr_description": "## 概要\n..."
}
1つのcommit_groupが1コミットになる。機能とテストを別コミットにしたり、独立した機能は分けたりとPRのコミット履歴が読みやすくなる。
is_fixable: falseも返せるようにしているのが重要で、チケットの記述が曖昧すぎて実装方針が判断できない場合にはPR作成をスキップして、チケットに「情報不足のためスキップしました」とコメントする。無理に生成させると変な差分が作られるだけなので、スキップの判断も明示的に返させている。
AIのJSONレスポンスが仕様どおりに来ない
「JSON only」と指示してもコードフェンス(```json ... ```)で包んで返してくることがある。キー名のスタイルもis_fixableだったりisFixableだったり揺れる。
まずコードフェンスを除去して、先頭の{からJSONを探す。
def _extract_json_object(self, text: str) -> dict | None:
decoder = json.JSONDecoder()
cleaned_text = self._strip_code_fence(text.strip())
for index, char in enumerate(cleaned_text):
if char != "{":
continue
try:
data, _ = decoder.raw_decode(cleaned_text[index:])
except json.JSONDecodeError:
continue
if isinstance(data, dict):
return data
return None
def _strip_code_fence(self, text: str) -> str:
match = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
return text
json.loads()ではなくraw_decode()を使っているのは、JSONの後ろに余分な文字があっても途中までパースできるから。
キー名は複数の候補を試す。
IS_FIXABLE_KEYS = ("is_fixable", "isFixable", "fixable")
REASONING_KEYS = ("reasoning", "reason", "summary")
COMMIT_GROUP_KEYS = ("commit_groups", "commitGroups")
def _get_first_present(self, data: dict, keys: tuple, default=None):
for key in keys:
if key in data:
return data[key]
return default
is_fixableに"true"(文字列)が来ることもあるのでboolへの変換も入れている。
ファイルパスはそのまま使わない
AIが返すファイルパスをそのままディスクに書くのは危ない。チケットにユーザーが書いた内容がそのままAIに渡っているので、パストラバーサル(../../etc/passwdなど)が含まれている可能性がある。
def _normalize_path(self, path) -> str | None:
if not isinstance(path, str):
return None
normalized_path = path.strip().replace("\\", "/")
if not normalized_path:
return None
if normalized_path.startswith(("/", "~")):
return None
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", normalized_path): # URI scheme
return None
parts = [part for part in normalized_path.split("/") if part]
if not parts or any(part == ".." for part in parts):
return None
return "/".join(parts)
絶対パス・ホームディレクトリ・URIスキーム・..を全部弾く。入力がユーザー由来のものを含む場合はAIが生成した値でも信頼しない。
2ステップの分割とJSONパースの堅牢化が実装の核になった。特にJSON周りは「仕様通りに来るはず」という前提で作るとすぐ壊れるので、最初から複数のキー候補とコードフェンス除去を入れておくのがおすすめ。