1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + OpenAI + Supabaseで競馬AI予想サービスを作った

1
Posted at

はじめに

趣味の競馬を自動化したくて、JRAの重賞・メインレースをAIが全頭分析して予想印を出すWebサービスを作りました。

生涯収支がマイナスなので、AIに助けてもらおうという動機です。


技術スタック

用途 技術
フレームワーク Next.js 15 App Router (TypeScript)
ホスティング Vercel(maxDuration=300s)
DB Supabase(PostgreSQL + RLS)
AI分析 OpenAI gpt-5-nano(reasoning_effort: low)
Web検索 gpt-4o-search-preview(3並列)

システム構成

出走表・オッズ・馬歴・調教・騎手成績を取得
  ↓
Web検索(展開予想・調教評判・コース傾向)× 3並列
  ↓
OpenAI に全データを渡して全頭分析
  ↓
Supabase に保存
  ↓
Next.js で表示

AI分析の実装

取得したデータを全部まとめてOpenAIに渡し、JSON形式で予想を返させます。

const userPrompt = `
【レース情報】
レース名: ${race.name}
開催場: ${race.venue}${turnDirection})
距離: ${race.surface}${race.distance}m

【出走馬一覧】
${entriesText}

【最新ウェブ情報】
${webInfo}

以下のJSON形式で回答してください:
{
  "race_comment": "レース全体の分析(400文字程度)",
  "horse_scores": [
    {
      "horse_name": "馬名",
      "horse_number": 馬番,
      "ai_score": 0〜100,
      "mark": "◎か○か▲か△か☆か×か-",
      "comment": "評価コメント(100〜160文字)",
      "is_ana_pick": true/false
    }
  ]
}
`

予想印のルール

頭数 条件
必ず1頭 本命
必ず1頭 対抗
必ず1頭 単穴
1〜2頭 連下
0〜1頭 穴馬(4番人気以内は不可)
× 0〜2頭 抑え

☆は4番人気以内に絶対つけないというルールをプロンプトで強制し、AIが出力したあとにもコードで検証しています。

parsed.horse_scores.forEach((h) => {
  if (h.mark === '') {
    const pop = entryMap.get(h.horse_number)?.popularity
    if (hasPopularityData && pop !== null && pop <= 4) {
      h.mark = h.ai_score >= 60 ? '' : '×'
      h.is_ana_pick = false
    }
  }
})

gpt-5-nanoあるある対策

gpt-5-nanoはコストが安い反面、JSON出力が不安定なことがあります。実際に遭遇したケースと対策です。

英単語で数値を出力する

"ai_score": sixty  // ← これが来る
const WORD_TO_NUM: Record<string, number> = {
  zero:0, one:1, ..., sixty:60, seventy:70, ...
}
jsonText = jsonText.replace(
  /("(?:ai_score|ana_score|horse_number)"\s*:\s*)([a-zA-Z]+)/g,
  (_, prefix, word) => {
    const val = word.toLowerCase().split('-')
      .reduce((sum, p) => sum + (WORD_TO_NUM[p] ?? 0), 0)
    return `${prefix}${val || 0}`
  }
)

キー名にスペースが入る

" horse_number"  // ← 先頭にスペース
for (const key of Object.keys(raw)) {
  const trimmed = key.trim()
  if (trimmed !== key) {
    raw[trimmed] = raw[key]
    delete raw[key]
  }
}

hourse_number とタイポする

if (raw.hourse_number !== undefined && raw.horse_number === undefined) {
  raw.horse_number = raw.hourse_number
  delete raw.hourse_number
}

Web検索を3並列で実行

gpt-4o-search-previewで以下の3クエリを並列実行し、最新情報を補完しています。

const queries = [
  `${raceName} ${year}年 展開予想 ペース予想 馬場傾向 競馬`,
  `${raceName} ${year}${topHorses} 調教 状態 仕上がり 競馬`,
  `${venue} ${course} コース傾向 有利 脚質 データ 競馬`,
]

const results = await Promise.allSettled(queries.map((q) => webSearch(q)))

まとめ

競馬 × AIは思ったより相性がよくて、データドリブンな分析を自動化できました。

3/14(土)の中山・中京・阪神、3競馬場の10R〜12R・合計9レースの予想をすでに出しているので、結果が楽しみです。

コードはまだ整理中ですが、興味があれば見てみてください。

AIは競馬で人間に勝てるのか。この実験はまだ始まったばかりです。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?