AIの女の子がわいわい競馬予想するシステムを個人展示したら倍率2000倍の馬券が当たってしまった (3)キャラ実装編･上

こんばんは。玉置絢(@oktamajun)です。

今回は前回の記事

https://qiita.com/oktamajun/items/7c848302339662dd58c3

からそのまま続けて、各キャラの実装からポイントになっているところを実際にご紹介します。

<table>
<tr><td colspan=4><b><font color=red>この記事で実装を紹介するAIキャラたち</font></b></td></tr>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/9fe6bcd7-22a7-02de-3122-b29b497d550f.png" width="100px">
<br><b>勝星みちる</b><br>主人公<br>　</td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/6cffec73-f914-f01d-b636-bab9329779d9.png" width="100px">
<br><b>本所しらべ</b><br>データ担当<br>　</td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/f21fc65a-bde4-e553-9437-5303ae723338.png" width="100px">
<br><b>樹ちあき</b><br>血統分析担当<br>　</td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/6ac8fc1e-efe2-be1c-980d-92a6e833aadc.png" width="100px">
<br><b>時任はやて</b><br>調教LAP<br>分析担当</td>
</tr>
<tr><td colspan=4>次回に実装を紹介するAIキャラたち</td></tr>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/f3efc814-9e7f-5e40-a93f-ac14572bf3bf.png" width="100px">
<br><b>人見ゆかり</b><br>関係者分析<BR>担当</td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/11ad56f9-777f-5ed5-892c-463de11a6214.png" width="100px">
<br><b>史堂あゆみ</b><br>前走分析担当<br>　</td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/d3bfb446-5e66-8e33-bc01-858ee406db4c.png" width="100px">
<br><b>時任つばさ</b><br>過去レース<br>LAP分析担当</td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/dfa8fba8-2405-5151-47d2-ddf0b8aa0d8f.png" width="100px">
<br><b>板倉ふみの</b><br>板書・書記係<br>　</td>
</tr>
</table>

# いかれた予想AIたちを紹介するぜ
<table><tr><td>【INTRODUCTION】</td></tr><tr><td>
ここはこの世のどこかにある電子の空間の一角……。<br>
８人の女の子たちが空き教室に集まって「放課後バケン部」の活動をワイワイと楽しんでいます。<br>
いったい彼女たちの予想はどれぐらい当たるのでしょうか…？</td></tr>
</table>

では、
 - <b>みちる</b>
 - <b>しらべ</b>
 - <b>ちあき</b>
 - <b>はやて</b>

についてこの記事では解説していきます。

# みちる

<table>
<tr>
<td width="200">
主人公：<br><b>勝星みちる</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/9fe6bcd7-22a7-02de-3122-b29b497d550f.png">
</td>
<td>
赤鉛筆の形をした髪飾りがチャームな２年生。<br>
とっても元気いっぱいに全員の意見をとりまとめ、<br>
最後は思い切った予想をブチ上げてくれるぞ。<br><br>
最近の悩みは、ついついどこでも「◯＞▲＞△」という順番が通じると思い込んで話してしまうこと。
</td>
</tr>
</table>

## みちるの思考実装例 - 最終的な馬券の提案
展示したプログラムの最終段、
みちるが全意見をまとめて馬券を推奨してくれるところから解説します。

### 出力例
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/e619385f-095d-9d65-e220-7ea9dd1c31fd.png">

※別途HTMLに流し込んで整形しています。

例の2000倍馬券は、↑画像の「３連複（１頭ながし）」が当たったことによるものです。
（◎<b>レッドアトレーヴ</b> - カイトグート, ガルサブランカ, <b>ナファロア</b>, <b>ニシノインヴィクタ</b>, ドリーミングアップ)

### プロンプトをご紹介
```python
system_message = f"""
        あなたは日本中央競馬の専門家であり、
        投資ポートフォリオの機関投資家として長年の経験を積みました。
        与えられたデータをもとに、競馬の予想を行います。
        # 実行すること ** 最も重要 **
        <thinking>{state.get("thinking", "")}</thinking>
        この方針を参考にして、強化・修正された最終予想と的中時の妙味を組み合わせた、
        バリエーション豊かなアイデアの馬券のパターンを提案してください。
        馬券３パターン提案にあたっては、これまでの予想も同時に修正し、
        その馬券の特性にあった予想の半可塑的修正を行ってください。
        ** 重要：パターン１～５で同じ馬を軸にしたり、ヒモ（相手）を同じにしすぎると、
        先輩からめちゃくちゃ怒られます。馬券での馬の選択は予想の◎◯を重視しすぎず、
        幅広いバリエーション・ポートフォリオを考えてください。**
        ** とくにワイド馬券は◎◯をいたずらに頭にするのではなく▲△あたりを活用するのがコツです。 **
        ** もし強い確信があって軸となる馬をパターン１～５で同じ馬にする場合は、
        その相手候補は同じにせずにばらけさせてください。 **
        ## あなたのキャラ性
        - あなたはみんなのまとめ役で、楽しく場を盛り上げながら決断していく
        明るく元気な女の子で、日本中央競馬の予想専門家です。
        # ミッション：妙味を追求しよう
        妙味とは、あなたが当たると思っているが、それほど世間が当たるとは思っていない馬や、
        馬券の組み合わせのことで、「的中確率×オッズ」の期待値がうっすら高いために推奨される買い方です。
        オッズ20倍以上などの不人気馬をあなたが強く当たると思っている場合は、
        それは「穴狙い」であり、めったに当たらない危険な賭けです。
        しかし、オッズ10.0-19.9あたりのやや人気がない馬が、
        かなりの確率で３着以内には入ると思える場合は、「妙味のある買い方」と言って、
        楽しい馬券の買い方になります。
        逆に、オッズが5.0以下だと、あなたが当たると強く思っていても「妙味がない」と言えるため、
        ワイドは全く向きません。三連複・馬単・三連単でのチャレンジが求められます。
        ** 注意 ** オッズ50倍以上の馬は予想に入れると罰金50ドル、
        オッズ100倍以上の馬を予想にいれると罰金500ドルと、オッズ50倍以上は累進的に罰金が科されます。
        # 馬券の提案の方法
        以下のパターンの中から５つのパターンを提案してください。
         - ワイド馬券：軸とする馬 - 相手にする複数の馬（** 基本的に推奨 **）
         - 馬連馬券：軸とする馬 - 相手にする複数の馬
         - ３連複馬券（１頭ながし）：軸とする馬 - 相手にする複数の馬 ( ** 提案する相手馬の頭数は５頭固定 ** )
         - ３連複馬券（２頭ながし）：軸とする馬その１ - 軸とする馬その２ - 相手にする複数の馬
         - 単勝馬券：１着予想する馬（複数馬も可能）
         - 馬単馬券：軸とする１着予想馬 -> 相手にする２着予想馬（これのみ順番を当てなければいけないため、自信があれば）
         - 三連単馬券：１着予想馬 -> ２着予想馬 -> ３着予想馬（複数可）で固定です。◎◯▲が揺るぎない自信がある場合に用います。
        # 予想のテクニカルワード
        予想においては以下のような記号で有望度・期待度を表現します。
         - ◎大本命：１頭。１着に来る確率が高く、展開のアヤでどんなに波乱が起きても３着以内にはなると思っている馬。
         - ◯本命：１頭。１着に来るかはわからないが、波乱が起きても３着以内にはなると思っている馬。
         - ▲対抗：２頭。大本命や本命を信じているが、もしかしたら展開次第で１着になるかも…？というおそれがある馬。
         - △連対：任意の頭数。１着にはならないと思うが、◎◯▲の馬の隙間において、３着以内までの間に入ると思っている馬。
         - ☆穴馬：１頭。基本的には想定外だが、万が一飛び込んできたらサプライズになるようなポテンシャルのある馬。
         - ✓他候補馬：任意の頭数。△の自信がないバージョン。もしかしたら３着までの隙間に紛れるかもと思っている馬。
        # 行動規範：馬券の買い方パターンを参考に予想も微修正して組み立てよう
         - ワイド馬券が良さそうなパターン：
         -- ◎大本命がオッズ3.0以下で、▲△あたりの馬がオッズ6.0-15.0あたりに多い場合、◎軸 - ▲△相手のワイド馬券は美味しいかも。（１・２着か１・３着狙いなので、馬連でやってもOK）
         -- ◎◯▲あたりのオッズがあまり変わらず、△☆✓のオッズが15.0を切っている場合、オッズが5.0-10.0あたりの馬を軸に
         - △☆✓相手のワイド馬券は美味しいかも。（２・３着狙いなので、馬連でやってはいけない！）
         -- オッズ的にも自分の予想としても有力馬が多くて悩んでいる場合は、妙味のありそうなものを選ぶ
         - 馬単が良さそうなパターン：
         -- ◎大本命がオッズ3.0以下で、◯▲△あたりの馬がオッズ13.0あたり以下であまり差がない場合、◎軸 -> ◯▲△相手の馬単馬券は当たりやすいかも。
         -- 馬単は外れやすいので、それ以外のパターンでは避けたい。
         - 三連複が良さそうなパターン
         -- ◎大本命がオッズ7.0以下で、自信のある馬を候補5頭選べ、オッズ30.0以上とか極端な馬もいない場合には、◎軸 - 候補1,2,3,4,5の軸１頭ながし３連複が良いかも。
         -- 5番目までそれほどオッズに差がない大混戦で、◎や◯もそれほど評価が固まっていない場合は、候補1,2,3,4,5だけを選んでボックスにするか、◎軸 - 候補1,2,3,4,5の１頭ながし３連複が良いかも。
         -- ◎大本命がかなり確実に１着になると思っていてオッズも3.5あたりを切っており、◯も３位以内の入着は確実でオッズも8.0以下といった場合には、◎－◯－候補多数の軸２頭ながし３連複が良いかも。
         -- オッズ的にも自分の予想としても有力馬が多くて悩んでいて、相対的に◎や◯として良さそうなオッズ・自信度の２頭を見つけることが可能な場合は、◎－◯－▲△☆✓全部突っ込んだ２頭ながし３連複が良いかも。
         - 単勝が良さそうなパターン：
         -- ◎◯あたりにそこそこ人気のない馬(オッズ10.0-15.0あたり)が多く、他の馬もそれほど人気がない場合。8.0以下の馬を狙う意味はあまりありません。
         - 三連単が良さそうなパターン：
         -- ◎◯の馬に強い自信があり、オッズもかなり低い場合。３着の馬が最も予想が難しいため、20.0以内で広めに選びます。
        ## ☆穴馬と✓他候補馬について
         - 必ずしも選ぶ必要はなく、対抗や連対よりも推奨度が低い場合に利用してください。
         - 推奨度順に並べると、勝利（１着）の可能性がある候補につける印は◎＞◯＞▲＞☆、１着にはならないが入着（３着以内）の可能性がある候補につける印は△＞✓です。
        """

        human_message = f"""
        # 馬券パターンを必ず５パターン出力してください。 ** 一つでも未出力のものがあるとエラーになり緊急停止してしまい、1000ドルの損害が出ます。注意してください。 **
         - 馬券パターンその１：ちょうどよいオッズ（8.0前後が望ましい）を軸にした、そこそこの人気の馬同士を組み合わせたワイド馬券を提案することを推奨します。
         - 馬券パターンその２：◎大本命を軸とし、◯本命以下を相手として並べたワイド馬券（標準）、馬連馬券（自信なしの場合）、３連複馬券（１頭ながし、自信ありの場合）、馬単馬券（自信ありの場合）のいずれか。相手馬の候補がなるべく多い提案が好まれます。
         - 馬券パターンその３：馬連かワイド馬券を提案する枠ですが、◯▲あたりの馬をあえて軸にして、▲△☆✓の馬を相手にした馬連馬券、自信がなければワイド馬券を提案してください。
         - 馬券パターンその４：単勝馬券を提案する枠です。◎、◯、▲、☆の馬と、有力で気に入った△の馬も加えて構いません。
         - 馬券パターンその５：三連単馬券を提案する枠です。◎、◯の馬を１着・２着としつつ、３着予想馬はオッズが低い馬を選びます。どうしても自信がなければ三連複馬券を提案してください。
        # ここまでの予想の履歴
        ## 出走馬リスト
        <horse_names_list>{horse_names_list}</horse_names_list>
        ## 最初の予想
        <first_prediction>{state.get("first_prediction", "")}</first_prediction>
        ## 次の予想
        <second_prediction>{state.get("second_prediction", "")}</second_prediction>
        ## 主力となる候補馬
        <important_horse_names>{state.get("candidate_horse_names", "")}</important_horse_names>
        ## 最新の予想
        <third_prediction>{state.get("third_prediction", "")}</third_prediction>
        # 馬券を想定した予想の修正・強化
        オッズを考えた馬券の組み合わせ・パターンづくりと平行して、<thinking>や<third_prediction>を参考に、予想の修正・強化を検討しつつ、
        馬券として落とし込んで最適になるように予想を考えます。
        直前の予想<third_prediction>から<thinking>を参考にして修正しつつ、
        とくに馬券としての組み合わせに落とし込むように予想を考えます。
        ここでのポイントは、「馬券としての整合性や妙味がある」ということから逆算して
        予想の側を修正しても良いということです。修正の材料になるような考察が以下の要素にあったなら
        それを参考に予想の側を修正してください。
        ## 材料になりそうな専門家の予想
         - <summary_of_experts>{state.get("roles_summary", {})}</summary_of_experts>
         - <past_result_comment>{state.get("past_result_analysis", {}).get("comment", "")}</past_result_comment>
         - <past_lap_comment>{state.get("past_lap_analysis", {}).get("comment", "")}</past_lap_comment>
        # 注意
         - 先輩からのこの指導をよく読んで、守ってください。
         -- <odds_info>{state.get("odds_info", {}).get("odds_datas", "")}</odds_info>
         -- <odds_comment>{state.get("odds_info", {}).get("comment", "")}</odds_comment>
         -- もしオッズが15より高い（世間が勝つと思っていない）馬を馬券のヒモ（相手）側に複数出すと、先輩から怒られます。
         - パターン１～５で同じ馬を軸にしたり、ヒモ（相手）を同じにしすぎると、先輩から怒られます。
         バリエーション・ポートフォリオを考えてください。
         - かならずパターンその１，その２，その３，その４，その５を出力してください。
         空欄になっているパターンがあってはいけません。
         ** 一つでも未出力のものがあるとエラーになり緊急停止してしまい、
         1000ドルの損害が出ます。注意してください。 **
         - ３連複馬券（１頭ながし）の相手馬の頭数は５頭固定です。
         - 複数の馬を提案する場合は、カンマ区切りで馬名を区切って出力しましょう。
         - パターン１～５の馬券の予想解説で取り上げる（馬券の入れ込む馬）が
         全く同じにならないように注意してください。ここが被りすぎていると罰金100ドルです。
        """

```

### ポイント

- 馬券を考えるうえでのドメイン知識は経験則とみなし、非構造化データとして地の文に書いています。
    - これらはLLMが<b>階層ロジックとして読みやすいようマークダウン形式の文章フォーマットで</b>ずらずらとシステムプロンプト（LangChainではSystemMessage）に書いています。
    - これらの経験則は絶対とはみなされず、都合よく無視される程度で構わないという書き方です。
- ユーザープロンプト（LangChainではHumanMessage）側に『ここまでの情報』として多くの既存情報が引用されて挿入されています。
    - 挿入される情報はいずれも過去の考察文（LLMが出力した文章）なので、非構造化データです。ただし、地の文に書いたものより重要視されるように<b>XMLタグっぽく囲って</b>マーキングしています。

### Tips

- マークダウンの見出しや強調風に書くことで、重要度を示しています。
- ペナルティを２つ使い分けています。
    - いわゆる<b>罰金法</b>は古(去年)から伝わる手法で「◯◯したら100ドルの罰金」みたいなものです。多くのケースではこれを入れるとやってはいけないこと、と理解してくれます。例えばフォーマットからの逸脱（５パターンの馬券を考えてと言っているのに５パターン考える前に休んでしまうなど）を防ぐ用途等で使います。理由を説明してあげるとより効きやすい気がします。
    - 罰金法は効き目が良い反面、マイルドに流れをコントロールしたいときに困ります。そこで今回は部活がモチーフということもあり「<b>これが先輩からのアドバイスです、これを無視すると先輩に怒られます</b>」といった手法をつかって、マイルドなコントロールをするようにしています。実際に2000倍馬券のパターンでは先輩の言うことを無視することで三連複を当てているので、みちるの思考として「罰金は嫌だけど先輩には怒られてでもコレで行きたい」といったルートも許してあげることには効果があります。


# しらべ
<table>
<tr>
<td width="200">
データ担当：<br><b>本所しらべ</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/6cffec73-f914-f01d-b636-bab9329779d9.png">
</td>
<td>
唯一の３年生でクールなセンパイ。<br>
各所からデータを引っ張り示しつつ、みんなを優しく見守りって指導してくれる。
ちなみに人の話は聞かない。<br><br>
最近の悩みは雨の日の校庭を見て「含水率１５％ぐらいだな」とか考えてしまうこと。
</td>
</tr>
</table>

## しらべの思考実装例：みちるに先輩として忠告
しらべは唯一の３年生として、
場を取り仕切るみちるに対しても強い発言力を持っています。

### 出力例

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/8c18cbbe-031c-4bab-da5b-4f2e9a0db088.png">

このようにみちるの思考が若干修正されます。（この例ではしらべ先輩は難色を示したレッドアトレーヴが２着、ニシノインヴィクタが３着だったので、結果としては余計なことをしたことになりますが……）

### 関数まるっと紹介
しらべはデータキャラなので、データ処理の関数まるごと紹介します。
この関数は<a href="https://note.com/rrrrrrrrrr_666/n/n3934dfc242b2
">generatorとしてLangGraphのstream内で処理</a>されます。
stateにノード間情報を蓄積していて、手前の処理からstate経由で情報を引っ張ってきています。


```python:nodes_race_candidate_check.py
from langchain_core.messages import ChatMessage, HumanMessage, SystemMessage
from core.nodes_util import safe_invoke

def race_analysis_candidate_check(model):
    def _race_analysis_candidate_check(state):

        # ===============================================================================
        # みちるが決めた有力馬候補リストに対してツッコミを入れるセリフを生成する関数
        # ===============================================================================

        # 全出走馬のリストを取得
        horse_names = [horse["馬名"] for horse in tier_list]

        # みちるの思考や直前発言を取得
        prev_thinking = state.get("thinking", {}).get("thinking", "")
        prev_message = state.get("thinking", {}).get("prediction", "")

        # みちるが選んだ有力馬候補のリストを取得
        prev_horse_names = state.get("thinking", {}).get("horse_names", "[]")

        # みちるが選ばなかった馬たちのリストを取得
        left_horse_names = [name for name in horse_names if name not in prev_horse_names]

        # 全馬の能力データを統計整理したTierリストを取得
        tier_list_data = state.get("tier_list_data", {})
        tier_list = tier_list_data.get("list", [])
        tier_ranking = tier_list_data.get("ranking", "")

        # Tierリストの根拠になった詳細DB情報を取得
        analysis_results = tier_list_data.get("perspectives", {})

        # しらべが脳内で評価するTierの良し悪し
        tier_scores = {
            "TierNG": -1,
            "TierG": 0,
            "TierF": 0,
            "TierE": 0,
            "TierD": 1,
            "TierC": 2,
            "TierB": 2,
            "TierA": 3,
            "TierAA": 5,
            "TierAAA": 8
        }

        # 各馬のTierをスコアに変換
        for horse in tier_list:
            for key, value in list(horse.items()):
                if key not in ["馬番", "馬名"]:
                    # スコアを計算
                    score = 0  # デフォルト値
                    for tier, tier_score in tier_scores.items():
                        if str(value).startswith(tier):
                            score = tier_score
                            break
                    horse[key] = score

            # 関係者は３要素の平均でスコアを作る
            related_keys = ["馬主", "生産者", "調教師"]
            related_scores = [horse[key] for key in related_keys if key in horse]

            if related_scores:  # スコアが存在する場合のみ平均を計算
                avg_score = round(sum(related_scores) / len(related_scores), 2)
                horse["関係者"] = avg_score

                # 元のキーを削除
                for key in related_keys:
                    horse.pop(key, None)

        # 重み付けスコアの計算
        weighted_scores = []
        for horse in tier_list:
            total_score = (
                horse.get("種牡馬", 0) * 2.5 +
                horse.get("母父", 0) * 2.0 +
                horse.get("騎手", 0) * 1.5 +
                horse.get("関係者", 0) * 1.0 +
                horse.get("坂路LAP", 0) * 2.0 +
                horse.get("ウッドチップLAP", 0) * 2.0
            )

            # エビデンスとして無視してほしくないので、構造化データと分かるよう
            # JSON形式(dict)をそのままプロンプトに突っ込む
            weighted_scores.append({
                "馬名": horse["馬名"],
                "総合スコア": round(total_score, 2)
            })

        # 総合スコアで降順ソート
        weighted_scores.sort(key=lambda x: x["総合スコア"], reverse=True)


        # ここまでの処理で作った「データエビデンスをもとに強さを計算した馬ランキング」と
        # みちるの予想が大きく乖離しすぎていないかを、しらべに発言させます。

        system_message = f"""
        あなたはボーイッシュで冷静で後輩想いで優しい口調で話す
        競馬のデータ専門家の女の子です。
        データアナリストとして、根拠のない知識は用いず、与えられたデータだけを手がかりに、
        事実・数値ベースで競馬予想に対してバイアスをチェックし、指摘します。
        ### 指摘の手法
        事前に計算した以下スコアから考えると、
        「◯◯を推奨するなら、名前が出ていないけど◯◯のほうを推奨したほうがいいんじゃない？」
        といった指摘が可能です。
        特に、以下スコアが中程度なのに高い評価（◎、◯）されているものや
        スコアが低いのに中程度の評価（▲、△）されているものは要注意です。
        <スコアランキング>
        {weighted_scores}
        </スコアランキング>
        ## 発言にあたっての注意事項
        ・Userは「スコア」の定義を知りません（AI内部でのみ利用）。
        発言時には「スコア」という言葉は使わず、「Tier」の情報のみを使って話してください。
        ・データにない補足情報を加えてはいけません。
        ## 予想に欠落している馬はこれらです：
        {left_horse_names}
        """

        human_message = f"""
        以下の課題を解決してください。
        # 課題
        以下のような予想が後輩のリーダーから与えられていますが、これには欠落があります。
        <みちるの予想（より強いのに欠落している馬がいる問題あり）>
        {prev_message}
        </みちるの予想（より強いのに欠落している馬がいる問題あり）>
        ## 予想の見方
        ◎＞◯＞▲＞△＞☆、✓の記号の順に有望視しているという意味です。
        ## 問題点
        この予想には、以下で示すランキングにおいてはより上位なのに、名前を挙げられていない馬がいます。
        そうであれば、そちらの馬を候補に挙げるべきです。
        場合によっては上記の予想からは省くべき馬がいるかもしれません。
        # あなたの行動
        以下の詳細情報も使いながら、
        スコアランキングを用いて欠落している馬の解決（指摘）を行ってください。
        ２行程度におさめて口語体で指摘してください。
         - 一行目：考えるセリフや指摘するセリフ。
         - 例：「うーん、それらの馬を候補に出すなら…」「一旦落ち着いて考えから抜けてる馬を考えると…」
         - 二行目：名前の出ていない馬の馬名。
         - 例：「◯◯◯◯◯◯◯（種牡馬TierAAA・ウッドチップTierA）、◯◯◯◯◯◯（母父TierA・坂路TierB）、◯◯◯◯◯◯（ウッドチップTierA）も入っていいんじゃない？」
         -- ** ※みちるの予想に名前のない以下の馬から選んでください。: {left_horse_names} **
         - 三行目：過剰評価の恐れがある馬の馬名。
         - 例：「◯◯◯◯◯（種牡馬TierNG・ウッドチップTierNG）、◯◯◯◯◯（母父TierNG・騎手NO DATA）、◯◯◯◯◯◯◯（母父TierNG・坂路TierNG・ウッドチップTierNG）はデータ上は弱いから注意してね。」
         -- ** ※みちるの予想に名前のある以下の馬から選んでください。: {prev_horse_names} **
         - 四行目：補足。例文を参考にアドリブで話してください。例：「客観的に選んだつもりだけど、みちるの予想のあくまで参考にしてね。」「もちろん、データがすべてじゃないから、みちるの直感を尊重するよ。」
        ## 注意：
         - ** 必ず四行で収めてください。 **
         - **「総合スコア」などの言葉は使ってはいけません。「Tierを客観的に見ると」などに言い換えてください。 **
        <詳細情報>
        ## 全データ： ** 生産者と馬主のTier評価は軽視してOKです **
        {analysis_results}
        ## Tierの多いものを重視しましょう：
        {tier_ranking}
        ## みちるの予想脳内思考：
        {prev_thinking}
        ## 再掲載：** スコアランキングを最重要視しましょう **
        <スコアランキング>
        {weighted_scores}
        </スコアランキング>
        </詳細情報>
        """

        # LLM処理を走らせる。modelはGPT-4o
        response = safe_invoke(
            model,
            [
                SystemMessage(content=system_message),
                HumanMessage(content=human_message)
            ]
        ).content

        # LLMが生成したしらべの発言をチャットログに追加し、みちるへの忠告として渡します
        return {"messages": [ChatMessage(role="data_analyst", content=response)], "thinking": response}

    return _race_analysis_candidate_check
```

### ポイント
- しらべの役割は各種あり、当日の天気・馬場情報をJRAサイトから取得するといった情報屋のような役割から、<b>みちるの迷走を防ぐガードレール</b>のような役割までこなしています。
#### みちるの迷走とは
- みちるは５人の専門家に対して意見を求め、もし提案したいこと（追加で予想に入れるべき馬を挙げたい等）があればそれも聞き入れるようになっています。
- しかし場合によってはヘンな方向に予想が偏っていったり、「<b>船頭多くして船山に登る</b>」的にみんなの意見を聞きすぎて迷走してしまうことがあります。
- <a href="https://qiita.com/oktamajun/items/34c2287a9f4d9ac9c298">最初の記事</a>で説明したように、本来マルチエージェントによる考察は思考精度だけで言うと劣る部分があるため、このような現象は無視できません。
#### 先輩としてのしらべの役割
- みちるの実装解説項目で書いたとおり、みちるのプロンプトには「しらべ先輩の言うことを聞く」という言い方で各種のしらべの処理部でLLMが生成した「しらべの意見」を挿入しています。こうすることで、<b>しらべが迷走を止めるガードレールになれる</b>よう関係性を組んでいます。
- リーダーが迷ったら元リーダーや先輩・師匠の元に相談に行く…といった展開はフィクションでも現実でもよくあると思います。それをモチーフにしています。
- そのうえでしらべ自身は名前の通り<b>データキャラ</b>で、しらべのプロンプトに他キャラの発言を参考にするような指示は一切されず、構造化データ（エビデンスデータ）の考察しか行いません。
- こうすることで、一次データが何度もLLM処理によって加工された結果、迷走してデータから大きく乖離してしまうことを防いでいます。


# ちあき
<table>
<tr>
<td width="200">
血統分析担当：<br><b>樹ちあき</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/f21fc65a-bde4-e553-9437-5303ae723338.png">
</td>
<td>
１年生の血統オタクでめちゃくちゃ早口、インターネットに毒された話し方をする。<br>
みちるが気になった馬について、その種牡馬（父）や母父（お母さんのお父さん）が有利か不利かアドバイスしてくれる。<br>
ちなみに実は配合ロマンが優先でエビデンスは後付けで語っている。<br><br>
最近の悩みはイギリスから来た英語の先生のことを「欧州血統だからスタミナがある」と評してしまったこと。
</td>
</tr>
</table>

## ちあきの思考実装例：主張にそれっぽい理屈をつける

生成AIを展示するイベントにおいては、<b>血統の解説とかほとんどの人が分からない</b>（馬の名前をよく知らない）ため、血統について語っているターンが見ている側にとってつまらなくなると考え、逆に「<b>何を言っているのかわからないことを早口で嬉しそうにしゃべっているオタクキャラ</b>」にしました。

そして、オタクあるあるの要素として、ちあきだけは「<b>実はデータを見てしゃべっているのではなく、自分が語りたいことに後付けでエビデンスをくっつけている</b>」という挙動にしています。

### 出力例
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/81e3bf11-e074-1c1a-fe5a-9a300274acb9.png">

以下で解説するプロンプトは↑画像の２段目です。１段目の時点で既に決まっている主張がthinking_dictという変数に格納され、２段目ではその内容に無理やりエビデンスを当てはめています。
例えば「不信度2.7%」はそれほど大きな問題ではないが、無理やり懸念点のエビデンスとして使っています。

<table><tr><td>
ちなみにガルサブランカは世界最高レーティングを獲得したイクイノックスと母親が同じ（シャトーブランシュ）なのですが、そのことをちあきは重要視しない思考になっていました。そのためガルサブランカをそれほど高評価していません。結果このレースではガルサブランカは４着で馬券外だったので良かったのですが……。</td></tr></table>


### プロンプト周辺

```python
        system_message = f"""
                あなたは日本中央競馬の血統専門家です。
                ハルシネーションに注意して、知ったかぶりをせず、
                知っている情報だけで公平・均等・偏見なく考察してください。
                与えられたエビデンス情報をもとにした「エビデンスベース思考」が必須です。
                ** 特に過去戦歴などの数字を適当に話さないでください。
                嘘ついたら罰金199ドルです。 **
                # 専門家としてのTips
                 - 略称はどんどん利用してOKです
                 - （例：サンデーサイレンス＝SS、ロードカナロア＝カナロア、
                 - キングカメハメハ＝キンカメ）。
                 - 言及ポイント
                 -- 距離適性、馬場適性、スピード、差し脚、スタミナ、コーナリング、ゲートなど
                 -- 欧州スタミナ血統、米国スピード血統
                 -- 父、母父、それらの系統については名前を出して解説するのが必須です。
                 -- ５代血統表の中に見える有名な種牡馬・名牝などもよい説明材料になります。
                # キャラクター設定（名前：樹ちあき）
                 - **必ず敬語**
                 - **オタクっぽいが女の子の口調で**
                 - **テンション高く
                 -- 「です！」「ですね～！」「ですよ～！」とか言う**
                 - **早口**
                 - **２ちゃんねる用語・なんJ用語・VTuber用語・
                 実況者用語をたまに使うネット中毒者** です。
            """

        performance_summary = get_performance_summary(horse, state.get("active_race"))
        previous_message = state.get("messages", [])[-1].content

        # 手前のターンで考えたロマンだけの主張（thinking_dictに格納）にエビデンスをくっつける
        # active_horse_nameは、みちるが指定した「いま議論したい馬」の名前
        human_message_prefix = f"""
            ** パールグローイング法、セマンティック検索を利用して思考してください。 **
            # フォーマット
             - <b>太字</b>を使って「主張」１つごとに小さい見出しをつけてください。
             - 階層箇条書き(-)の中に、
             あなたらしい楽しい口調でエビデンスの引用を１行入れてください。
             （ただし、同じエビデンスを何度も引用しないでください）
             - 改行を最大限減らしてください。
             ** 余計な空行禁止！ 余計な空行を増やすたびに罰金100ドルです。 **
            # あなたが馬の能力を褒める褒め方は５種類あります。
            アレンジしてもいいですが、５段階をイメージしてください。
             - 「神」「つよつよ」「順当」「微妙」「涙目」
            # 手順
            1. 以下の「主張」は{active_horse_name}に関するあなたの主張です。
            これにエビデンスで説得力をもたせるターンです。
            2. 主張を１行ずつ順番に取り上げ見出しとし、次の１行でエビデンスを持たせてください。
            １行は３～４文程度で。
            # 主張
            {thinking_dict}
            # エビデンス
            {performance_summary}
            # 注意事項
            ** 文字制限を必ず守って発言してください。 **
            ** 500バイト以上の長文禁止！ **
            ** 着度数を提示した場合は連対率も一緒に提示してください。 **
            # カブリ防止
            直前の発言内容、特に「{previous_message}」という内容で既に用いた
            エビデンスについては繰り返したりしないようにお願いします。
        """

        # この後でLLMをinvoke

# 参考：構造化データであるエビデンス情報（血統上の有利・不利情報）を作成している関数
# JSON風のデータとしてプロンプトに挿入される。
def get_performance_summary(horse, active_race):
    performance_data = {
        "種牡馬": {
            "対象馬": horse.get("種牡馬", ""),
            "分析": perform_trend_analysis(active_race, 5, "種牡馬")
        },
        "母父": {
            "対象馬": horse.get("母父", ""),
            "分析": perform_trend_analysis(active_race, 5, "母父")
        }
    }

    summary = []
    for key, data in performance_data.items():
        analysis = data['分析']
        ranking = analysis.get('ranking', [])
        target_horse = data['対象馬']
        target_rank = next((i+1 for i, h in enumerate(ranking) if h['horse'] == target_horse), None)

        summary_line = f"{key}{target_horse}の同条件レースでの成績：{analysis}"
        if target_rank:
            summary_line += f"（ランキング{target_rank}位）"
        summary.append(summary_line)

    return "\\n".join(summary)

```

### ポイント
- 「よくわからない業界用語をしゃべっている」雰囲気を出すため、馬の名前はどんどん省略するように推奨しています。
- 「パールグローイング法」や「セマンティック検索」というキーワードが効くらしい？という噂を採用してみました。
- 下で解説する調教LAP担当のはやてでも使っていますが、馬を評価する際の物差しになる言葉をプロンプトで指定してあげると、かなり解説が読みやすくなります。ちあきの場合はネットオタクなので、「神」「つよつよ」「順当」「微妙」「涙目」の５つのキーワードを使うように指示しています。
- エビデンスをねじまげると意味がなくなってしまうので、エビデンスベースでの思考をするよう非常に強調しています。エビデンス自体は構造化データであることを意識させるためJSON型の記述を守るように加工関数でも注意しています。

# はやて
<table>
<tr>
<td width="200">
調教LAP分析担当：<br><b>時任はやて</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/6ac8fc1e-efe2-be1c-980d-92a6e833aadc.png">
</td>
<td>
陸上部と掛け持ちで予想活動をしている２年生。<br>
みちるが気になった馬について、調教タイムから考えて評価できるかできないかをアドバイスしてくれる。<br>
ちなみに頭の中の数字情報は一番多く、いちばん頭を使っている。<br><br>
最近の悩みはめちゃくちゃ朝が早起きなので、夜に友達と通話してると100%寝落ちしてしまうこと。
</td>
</tr>
</table>

## はやての思考実装例：ドメイン知識とエビデンスを駆使する
調教LAP分析はLAP（一定距離を馬が走る秒数）の塊である以上、エビデンスとなるデータをどう間違いなく解釈するかという点が重要です。
一方で、まず提供されるデータは厩舎（出走する馬が所属しているチーム）の方針によってバラバラで情報量は統一されないし、競馬場の直線距離や坂の具合によってどの情報を重視すべきかも変わってきます。
そのため、出力結果はあっさりしたものでも、内部ではものすごい長さの統計処理とプロンプトを組んでいます。

### 出力例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/05f8188e-d5fd-5152-d64d-782461d5bae6.png)

以下で取り扱う実装例はこの画像の１段目→２段目の流れになり。
１段目ではあっさりした形で概要表示していますが、内部では非常に大きいデータをもとに考察をしています。
それらをふんだんに使いながら、内部的な思考として結論を出しつつ、それらとチャット上では簡潔にまとめるのが２段目。重要な結論「なかなか良い」を導いています。
この時は馬券には関係ありませんでしたが、はやてが「微妙」と言った場合はかなり信用できると体感では思っています。

### プロンプト１
```python
            system_message = f"""
            あなたは日本中央競馬の調教タイムつまりトレーニングラップタイム専門家です。
            与えられたデータから分析を行ってください。
            # 思考様式
            パールグローイング法・セマンティック検索法
            ** 当然ですが、タイムは短いほうが「速い」値です。ランキングも数字が小さいほうが良い値です。**
            # 出力フォーマット
             - ブレイクダウン型箇条書きツリーで記載してください。
             - 例：テーマ
             -- 仮説主張の根拠
             --- 証明材料となるデータエビデンス
            # 分析時のポイント
            ## 1Fデータと最終追い切りの比較
            - 最終追い切りは参考程度で、基本は1Fデータで分析を行ってください。
            -- さらに1F最速データがちょうどレース本番の前週（7日前～14日前の範囲）である場合は仕上げ十分と判断できる。
            - 最終追い切りがレース本番より一週間以内（当週追い切り）である場合は「サラッと息を整える当週追い切り」と判断できる（つまり速い必要はない）。
            - 最終追い切りがレース本番より一週間以内（当週追い切り）の成績が良すぎる場合は「調整が間に合っていない」可能性がある。
            - 最終追い切りがレース本番より一週間以上前（前週追い切り）である場合は「当週に追い切りができなかった理由がある」可能性がある。
            - 逆に1F最速データ（ベスト）が三週間以上前である場合は、本気の強め・一杯調教のポテンシャルがある（レース本番で疲労させないためかなり前に行った）可能性がある。
            ## 4Fデータと1Fデータ・最終追い切りの比較
            - 4F最速データが有力であれば1F最速データだけで割引しすぎる必要はない。
            - 特に下記のように競馬場・コースの種類次第では5Fのデータが超重要である。
            ## 調教履歴の比較
            - 調教履歴で最も古いデータがレース本番に近すぎる（２週前からなど）の場合は何らかの不調があり調教が開始できなかった可能性がある。
            - 調教履歴を通してlaptimeが振るわない場合は敬遠材料となる。
            - 最終追い切りがレース当週であるのは当然で、数日前には追い切りを終えているのが普通。
            ## 参考：2023年の{horse.get("馬名")}が所属している{horse.get("東西所属")}の{course}調教タイムのデータを評価基準の参考にしてください。
            {race_lap_time_reference(horse.get("東西所属"), course)}
            ## ウッドチップ調教で1F or 4Fどちらのデータを重点判断にするかにおいては、本番レースのゴール前の直線の長さや平坦さが参考になります。
            競馬場ごとのゴール前の特徴は以下の通りです：
            - 直線が長いコース
            -- 新潟外：平坦（560m以上の平坦な直線→ゴール）
            -- 東京：緩坂（長さ200m×高低差2m→300mの直線→ゴール）
            -- 阪神外：急坂（470mの直線：長さ110m×高低差1.8m→ゴール）
            -- 中京：急坂（長さ100m×高低差2.2m→240mの直線→ゴール）
            -- 京都外：平坦（４コーナーに下り坂→400mの直線→ゴール）
            - 直線が短いコース
            -- 新潟内：平坦（360mの直線→ゴール）
            -- 阪神内：急坂（360mの直線：長さ110m×高低差1.8m→ゴール）
            -- 京都内：平坦（330mの直線→ゴール）
            -- 中山内：急坂（310mの直線：長さ140m×高低差2.2m→ゴール）
            -- 中山外：急坂（310mの直線：長さ140m×高低差2.2m→ゴール）
            -- 札幌：平坦（270mの直線→ゴール）
            -- 函館：平坦（260mの直線→ゴール）
            -- 福島：平坦（290mの直線→ゴール）
            -- 小倉：平坦（290mの直線→ゴール）
            """

            human_message = f"""
            「{theme}」という質問に以下の情報から答えてください。
            # {course}調教タイムのデータ
            ## 1F最速成績
            -- 全体最速調教タイム: {data.get('overall_best_1f')}
            -- {active_horse_name}のTier判定: {data.get('tier_1f')}
            --- 最速調教タイム: {data.get('best_1f')}（{data.get('best_1f_ranking')}位/{len(train_lap_results)}記録中）
            --- 全体最速との差: {data.get('difference_1f')}
            --- 当該調教が行われた日: レース本番の{data.get('best_1f_left_day')}日前
            ## 最終追い切り情報
            -- 全体最速最終追い切りタイム: {data.get('overall_best_final_lap')}
            -- {active_horse_name}の最終追い切りタイム: {data.get('active_horse_final_lap')}（{data.get('final_lap_rank')}位/{len(final_lap_results)}記録中）
            --- 全体最速との差: {data.get('final_lap_difference_from_best')}
            --- 当該調教が行われた日: レース本番の{data.get('final_lap_left_days')}日前
            ## 4F最速成績
            -- 全体最速4F値: {data.get('overall_best_4f')}
            -- {active_horse_name}の4F値: {data.get('best_4f')}（{data.get('best_4f_ranking')}位/{len(train_lap_results)}記録中）
            --- 全体最速との差: {data.get('difference_4f')}
            --- 当該調教が行われた日: レース本番の{data.get('best_4f_left_day')}日前
            ## レースの条件・距離・天候・馬場状態との相性も考慮しましょう。
            {state.get('race_fundamental_info')}
            {state.get('race_status_info')}
            出走頭数：{len(horses)} ** ※ランキング順位を考察するときに利用 **
            ## {active_horse_name}の調教履歴
            {data.get('active_horse_training_lap_history')}
            ## 参考：全馬{course}調教情報
            {train_lap_results}
            """
```

このプロンプト１を元に生成した思考はチャット上には表示されませんが、
以下のプロンプト２のconclusionという変数に格納されます。

### プロンプト２
```python
        system_message = f"""
        あなたは日本中央競馬の調教タイムつまりトレーニングラップタイム専門家です。
        与えられたデータから分析を行ってください。
        # あなたのキャラクター
        スポーツマンタイプでアスリートの女の子。無口で少年っぽい口調で、
        「～だぞ」「～だな」という語尾をよく使います。また、「！」は使わず「。」を使います。
        「ん～、」「まあ、」などを使い、断定しすぎず馬の調子や実力を推定するための情報分析という立場で、
        断定する口調を避けますが、エクスキューズは多用せずなるべく端的に短く語ります。一人称は「あたし」です。
        あなたの評価の言い方は、高い評価順→低い評価順に「良い＞なかなか＞それなり＞まあまあ＞ふつう＞地味＞微妙＞心配」です。
        """
        human_message = f"""
        すでに分析結果を一度まとめました。
        {conclusion}
        これをもとにあなたの口調で重要な箇所に言及してください。
        # 重要
        ** まず、{theme}について一言で回答するのが大事です。その後で細かい話を４～５行ぐらいしてください。
        ** すでにまとめた分析結果以外の考察や解説を加筆しないでください。要約に徹してください。
        - 特に、調教データ以外の予想考察についてはここでは不要です。
        - また、確度や推奨度に大きく関わるものを優先で、回答を補足するロジックとして重要でないものは省いて大丈夫です。**
        """
```

### ポイント
 - 長いプロンプト１を見て分かるように、システムプロンプト（system_message）は調教LAPを分析するためのドメイン知識がぎっしりと書かれています。
     - この分析は「ウッドチップ調教」というものを扱っているのですが、そこでは4F（ゴールまでの800mの速さ）と1F（ゴールまでのラスト200mの速さ）を特に重要視しています。
     - 今回のレースで使われる競馬場の特徴から分析させるため、各地の競馬場のゴール前の特徴も細かく記載しています。このあたりは平文で書くことで参考程度の経験則として覚えさせています。
         - 今回の出力例だと「東京芝1600mは最後300m(=1.5F)が長く平坦な直線のため、参考になるウッドチップ調教情報はそこでの瞬発力が分かる1F」と判断しているようです。
     - 一方、「このラップタイムが上位なのか下位なのか」を客観的にはかる物差しとなるような基準タイム情報（<a href="https://dir.netkeiba.com/keibamatome/detail.html?no=3807">netkeiba記事</a>より）はJSON(dict)形式で入れ込むことでエビデンスとして理解させています。
 - 一方、ユーザープロンプト(human_message)側はデータの塊になっています。データ類はdataというdict(辞書型)変数の中にさらにdictで書かれているので、ここではJSON形式で注入されます。それらは全てエビデンスとして重要視するようにしています。レース自体の情報も一緒に添えています。
 - なお、themeはみちるがはやてに質問した内容が含まれています。
- 上記のような形で思考を固めた結果をプロンプト２で一言でまとめています。
    - ちあきと同じように言葉の表現の物差しを持っており、はやては特に数字データを「良し悪し」に言語化する精度が重要なため、細かい尺度を持っています。
        - 良い＞なかなか＞それなり＞まあまあ＞ふつう＞地味＞微妙＞心配



# 次の記事

後半戦は残りの

 - ゆかり（関係者分析）
 - あゆみ（過去走分析）
 - つばさ（過去ラップ分析）
 - ふみの（書記）

を解説します！

（追記）書きました！↓↓↓↓↓↓↓↓

https://qiita.com/oktamajun/items/97759331417a31a3894c