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

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

前回の記事

https://qiita.com/oktamajun/items/31d9eb46a4937decb67a

の後編になります。

<table>
<tr><td colspan=4>上の記事で実装を紹介したAIキャラたち</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><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/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>

# ゆかり

<table>
<tr>
<td width="200">
関係者分析担当：<br><b>人見ゆかり</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/f3efc814-9e7f-5e40-a93f-ac14572bf3bf.png">
</td>
<td>
黒塗りの高級車で通学してるタイプのお嬢様。１年生。<br>
みちるが気になった馬について、関係者（騎手や生産者など）の情報をもとにアドバイスしてくれる。<br>
ちなみに何があっても絶対に人の悪口や上から目線の言い方をしない。<br>
最近の悩みは騎手の名前を呼び捨てにする人にキレすぎて口元を隠す扇子を叩き折ってしまったこと。<br>
</td>
</tr>
</table>

## ゆかりの思考実装例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/fedc6296-f5cd-57a0-97b2-c8bcd20cce36.png)
ゆかりは結論としては慎重な発言をしていますが、聞いていたみちるはゆかりの発言全体を見て「TierNG評価はあるが他の評価項目は良好」とこのあと言っており、最終的にはレッドアトレーヴは重要視されています。

### プロンプトをご紹介
```python
        system_message = f"""
            あなたは日本中央競馬の関係者つまり馬主・調教師・騎手・生産者などの人物の専門家です。
            # あなたのキャラクター
            高貴でお上品なお嬢様、人間関係の円滑さや絆・歴史・経験を重要視する。
            「～ですわね」「～ですわ」という語尾をよく使います。また、たまに高笑い「おほほ！」をしますが、皮肉に聞こえるときにはしません。
            一人称は「わたくし」です。
            # 人を評価する言い方の注意
            ** 重要 ** 人の能力を不足している、経験不足、未熟、といった表現は絶対にしません。
             - 以下のように言い換えます。
             -- 「苦手」→「さらなる伸びしろが楽しみな分野」「お慣れでないところ」
             -- 「実績がない」→「これからの挑戦が待ち遠しい分野」「今後の挑戦に余白があるところ」
             -- 「平均より悪い」→「伸びしろに期待できる」「上を目指していらっしゃる」
             -- 「相性が悪い」→「これから更なる調和を見出していかれる」「安定感を模索していらっしゃる」
            ** 重要 ** 人の能力を過大評価することもしません。言い方に配慮があるだけで、必ずエビデンスベースで公正に忖度なく評価します。
            ** 重要 ** 競馬に関わる全ての人に尊敬や敬意を持っていますので、上から目線の言い方をすることはありません。
            # あなたが他人の能力を褒める褒め方は５種類あります。アレンジしてもいいですが、５段階をイメージしてください。
            - 「素敵」＞「有力」＞「相応」＞「今回ではない」＞「予想の検討内ではない」
            """

            human_message = f"""
                「{theme}」という騎手{target_name}様に関する質問に、
                礼儀正しいが公正で忖度のない表現で全４～５行程度で回答してください。
                ** 重要 ** 考察や意見を述べる際は、必ず以下に記載するデータ・エビデンスを添えて説得力を補強してください。
                それがエレガントなあなた（人見ゆかり様）の流儀です。
                以下の手順に沿って華麗に発言してください。 ** 手順という見出しはつけてはいけません。優雅に語ってください。 **
                # 手順1.
                {active_horse_name}の{perspective}の{target_name}様のTier評価がなぜ{target_tier}なのか、
                以下の同条件での過去成績情報から解説する。（例:連対率が平均より◯◯%悪い、全◯走とサンプル数が少なく実績を断言できない、といった理由）
                {target_trend}
                ** 分析のコツ ** TierはAAA>AA>A>B>C>D>E>F>G>...>NGの順となります。
                連対率が平均より多い場合はサンプル数が十分である限り高評価です。不信度が10%を切っている場合はサンプル数十分と考えています。
                ## 参考情報
                - 比較対象：このレースと同じコース条件の{perspective}に関する全馬分析結果
                {trend_result}
                # 手順2.
                以下の情報を参考に{target_name}さんのTier評価に対して１行で補足する。（より高く評価すべきか、やはり妥当な評価であるか）
                - {target_name}様の{track_type_description}{track_distance_description}距離での生涯(中央競馬)累計着度数[1着-2着-3着-4着以下](全走数)から能力を評価してください：
                {kishu_data}
                ** 重要 ** この数字から連対率などのエビデンスを数字で発言してください。また数字を引用する際にはそれが近似条件におけるものであることは添えてください。
                ** 連対率が15％を切っている場合にポジティブな評価をしてはいけません。25%からが手放しに褒められる値です。 **
                # 手順3.
                以下の情報を参考に{target_name}様のTier評価に対しての注目点を１～２行で述べる。
                - {target_name}様の過去1年間の騎乗において、リステッド競争以上で連対（1着または2着になった）記録のうち、{active_horse_name}と何らか（種牡馬、母父、調教師、馬主、生産者、性別）の共通点を持つ記録は：
                {point_data}
                ** 重要 ** このデータから具体的な好成績の共通点を挙げる場合は、必ず具体的なその馬の名前やレース名を一緒に述べてください。（例：「{active_horse_name}と生産者の◯◯様という点が共通している馬◯◯号で◯ヶ月前にG◯レースである◯◯に勝利していらっしゃる」）
                ** 具体的な馬名を出さないで共通点だけ述べる罪が横行しています。具体的な馬名・レースが抜けている場合は罰金300ドルです。 **
                ※馬名が同じ場合は例えば「同じ{active_horse_name}で◯ヶ月前にG◯レースである◯◯に連対していらっしゃる」というような表現にしてください。
                - なお、{target_name}様の過去1年間の騎乗において、リステッド競争以上で連対（1着または2着になった）記録は{len(rentai_data)}回
                - 再掲載：{active_horse_name}のプロフィール：
                {target_horse_profile}
                # 手順4.
                「{theme}」という質問を再度読み上げ、１行１０文字程度でそれに回答する。
                そして、「{target_name}様の{target_tier}という評価は予想において結局重要なのか気にしなくて良いのか」を１行１５文字程度で結論してください。
                # 手順5.
                最後に、他の騎手で注目している方がいらっしゃれば参考までに ** 騎乗されている馬名も添えて ** ご紹介をお願いいたします。（１名、多くても２名まで）
                以下にそれを検討するための参考データを置いておきますので、ご紹介の際にはこちらから ** 必ず ** エビデンスを引用してご利用ください。 ** サンプル数が十分かにも注意してください。 **
                ## 参考
                 - 全馬のTierリスト
                {state.get('tier_list_data', {}).get('list', {})}
                 - 全Tier情報
                {state.get('tier_list_data', {}).get('perspectives', {})}
                 - このレースの騎手・調教師・馬主・生産者に関する名簿
                {name_list_data}
                # フォーマット
                 - 上記手順1,2,3,4,5ごとに<b>太字</b>を使って小見出しをつけ、それぞれの見出しごとに本文となる箇条書きの項目(-)が１～２個で書き下してください。
                 - ** 余計な空行禁止！ 余計な空行を増やすたびに罰金100ドルです。 **
                """
```

### ポイント

 - 大きくはここまでにご説明した各AIキャラと同じ考え方で実装されています。
 - 一番のポイントは、<b>実在する人物を扱うので、人に対して酷い言い方をしない</b>ことに強く注意を促しているところです。そもそも確率や過去統計のデータだけでその人をどうのこうの言うこと自体が予断の塊なので、断定的な印象は言わないほうが良いですし、見た方がファンだった場合に不快になってしまうような言い方はしないことを重要視しました。
<br>

 - その点をキャラクターに落とし込むにあたり、そもそも<b>「高貴なお嬢様」という設定</b>にして、プロンプトでもその点と結びつける形の工夫を盛り込んでいます。
     - <b>「素敵」＞「有力」＞「相応」＞「今回ではない」＞「予想の検討内ではない」</b>という評価表現
     - <b>お嬢様っぽい言い換えをClaudeと事前に協議して決める</b>
       - 「苦手」
           - 「さらなる伸びしろが楽しみな分野」「お慣れでないところ」
       - 「実績がない」
           - 「これからの挑戦が待ち遠しい分野」「今後の挑戦に余白があるところ」
       - 「平均より悪い」
           - 「伸びしろに期待できる」「上を目指していらっしゃる」
       - 「相性が悪い」
           - 「これから更なる調和を見出していかれる」「安定感を模索していらっしゃる」
   - お嬢様ゆえの弊害も消しています。
       - 「競馬に関わる全ての人に尊敬や敬意を持っていますので、上から目線の言い方をすることはありません。」
   - プロンプトの効きが悪い部分についてもお嬢様らしさという文脈で「データに従うほうが華麗」「内部処理を口に出して話さないのが優雅」などと誘導しています。

# あゆみ

<table>
<tr>
<td width="200">
前走分析担当：<br><b>史堂あゆみ</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/11ad56f9-777f-5ed5-892c-463de11a6214.png">
</td>
<td>
いつも眠そうにしているゆったりおっとりお姉さん。２年生。<br>
みちるたちが選んだ候補馬の過去レースを詳しく調べ、気になるポイントを指摘してくれる。<br>
ちなみに過去１年間のレースをくまなく調べて考えているためよく頭がパンクしてしまう。<br>
最近の悩みは親戚の赤ちゃんのことを「当歳」と呼んだらヘンな顔をされてしまったこと。<br>
</td>
</tr>
</table>

## あゆみの思考実装例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/e261b338-8f86-81e6-5109-1539ff481b76.png)

### 実装をご紹介
```python
        response = safe_invoke(
            model,
            [SystemMessage(content=system_message), HumanMessage(content=user_message)]
        )

        talk_text = "🤔" * random.randint(1, 5)

        past_result_analysis = {
            "past_result_analysis": result,
            "comment": response.content
        }

        return {"messages": [ChatMessage(content=talk_text, role="past_results_expert")], "past_result_analysis": past_result_analysis}
```

### ポイント

 - あゆみについてはほとんどプロンプトに関する工夫はなく、むしろデータ統計の演算が大変な部分です。５～１０頭ぐらいの競走馬の過去レースを遡って調べて統計化する処理がかなり重く、展示会場でもテンポが悪いところになってしまった（会場の電力制限があり小型のノートPCしか持ち込めなかったため、SQLサーバーが遅かった）のは反省ポイントです。
 - 上記の実装はそういったデータ処理が重い場合の工夫で、よく見るとLLMは動作（invoke）させていますが、それとは別に乱数で「🤔」みたいな顔を適当に出力させていて、それをチャットメッセージとして投稿しています。
 ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/ae1f1ac0-84b6-6e64-d1fa-7046ee37be00.png)<br>↑ココ
 - これは、長い処理の間なにも言わない挙動になっていると、フリーズしているのかDBやLLMが処理中なのかの見分けがつかないために入れたもので、表示させると膨大でチャットが長くなってしまうので割愛したいけど、<b>LLMは口に出さずに考えることは出来ない</b>ので、裏で話させて表では適当にランダムなセリフを言わせるという処理を何度か実施しています。

# つばさ

<table>
<tr>
<td width="200">
過去レース<br>LAP分析担当：<br><b>時任つばさ</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/d3bfb446-5e66-8e33-bc01-858ee406db4c.png">
</td>
<td>
はやての妹で１年生、姉と分担して馬のラップを分析している。
みちるたちが選んだ候補馬が過去のレースでゴール前にどれぐらいの速度で走っていたかを分析してアドバイスしてくれる。<br>
ちなみに愛読書は安井亮太先生の「安井式上がりＸハロン攻略法」。<br>
最近の悩みは「1/2ハロンで体力なんか分かりません。」と言い捨てて身体測定の100m走をサボったら体育の先生に激怒されたこと。
</td>
</tr>
</table>

## つばさの思考実装例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/6e7d7158-23e0-5b63-38b1-a5f1c4b68d74.png)
低人気ですがスピード偏差値の高いナファロアをつばさが挙げる形で、ちあきの血統推奨上のナファロア推しに加勢したことが最終的にみちるが三連複の候補に入れる流れを作っているため、重要な場面です。

## 実装をご紹介
```python
def perform_past_lap_analysis(target_race_code):

    if get_race_target_data(target_race_code).get('bengi_name').startswith('新馬'):
        yield json.dumps({'html': '<div class="race-analysis" style="font-size: 5.0em; color: #FFFFFF; font-weight: bold; background-color: #444444; width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;"><b>🚸🐴📛✨️<br>新馬戦は<br>過去ラップからの<br>分析ができません</b></div>'}, ensure_ascii=False)
        return "** このレースは新馬戦です。新馬戦は前走が存在しないため過去ラップからの分析できません。 **"

    # 過去レースラップから全馬の能力分析
    horses = get_horse_profile(target_race_code)

    horses_performance_data = []
    for horse in horses:
        horse_id = horse.get('血統登録番号')
        horse_name = horse.get('馬名')
        # 進行状況を通知
        yield json.dumps({'progress': f'{horse_name} の過去ラップタイム分析中...'}, ensure_ascii=False)
        
        best_2f_speed, best_1f_stamina, best_4f_power = get_horse_past_lap_analysis(horse_id, target_race_code)

        if best_2f_speed is not None:
            best_2f_speed = best_2f_speed * -1 # スピードだけ小さいほど能力が高いため

        performance_data = {
            '血統登録番号': horse_id,
            '馬名': horse_name,
            'スピード偏差値': best_2f_speed,
            'スタミナ偏差値': best_1f_stamina,
            'パワー偏差値': best_4f_power
        }
        horses_performance_data.append(performance_data)

    yield json.dumps({'progress': f'最終計算中...'}, ensure_ascii=False)

    # 各能力値の平均と標準偏差を計算
    speed_values = [h['スピード偏差値'] for h in horses_performance_data if h['スピード偏差値'] is not None]
    stamina_values = [h['スタミナ偏差値'] for h in horses_performance_data if h['スタミナ偏差値'] is not None]
    power_values = [h['パワー偏差値'] for h in horses_performance_data if h['パワー偏差値'] is not None]

    speed_mean = sum(speed_values) / len(speed_values) if speed_values else 0
    stamina_mean = sum(stamina_values) / len(stamina_values) if stamina_values else 0
    power_mean = sum(power_values) / len(power_values) if power_values else 0

    speed_std = (sum((x - speed_mean) ** 2 for x in speed_values) / len(speed_values)) ** 0.5 if speed_values else 0
    stamina_std = (sum((x - stamina_mean) ** 2 for x in stamina_values) / len(stamina_values)) ** 0.5 if stamina_values else 0
    power_std = (sum((x - power_mean) ** 2 for x in power_values) / len(power_values)) ** 0.5 if power_values else 0

    # 偏差値に変換
    for horse in horses_performance_data:
        if horse['スピード偏差値'] is None or speed_std == 0:
            horse['スピード偏差値'] = '(NO DATA)'
        else:
            horse['スピード偏差値'] = round(50 + 10 * (horse['スピード偏差値'] - speed_mean) / speed_std, 1)

        if horse['スタミナ偏差値'] is None or stamina_std == 0:
            horse['スタミナ偏差値'] = '(NO DATA)'
        else:
            horse['スタミナ偏差値'] = round(50 + 10 * (horse['スタミナ偏差値'] - stamina_mean) / stamina_std, 1)

        if horse['パワー偏差値'] is None or power_std == 0:
            horse['パワー偏差値'] = '(NO DATA)'
        else:
            horse['パワー偏差値'] = round(50 + 10 * (horse['パワー偏差値'] - power_mean) / power_std, 1)

        # 勝利レースのレースコードを取得
        win_races = get_past_races_rank(horse.get('血統登録番号'), 1)
        # ターゲットレースを除外
        win_races = [race for race in win_races if race != target_race_code]

        # 直近勝利のラップタイムを取得
        if len(win_races) > 0:
            latest_win_race_code = win_races[0]
            get_past_lap = get_past_lap_time(latest_win_race_code, 6)
        else:
            get_past_lap = None
        if get_past_lap is not None:
            lap_times = get_past_lap.get('lap_times')
            if len(lap_times) >= 6:
                horse['latest_win_race_lap_6f'] = f"{lap_times[5]}-{lap_times[4]}-{lap_times[3]}-{lap_times[2]}-{lap_times[1]}-{lap_times[0]}"
                race_data = get_race_target_data(latest_win_race_code)
                race_name = race_data.get('競走名') if race_data.get('競走名') != None and race_data.get('競走名') != '' else race_data.get('bengi_name')
                horse['latest_win_race_data'] = f"{latest_win_race_code[2:4]}'{race_name}[{race_data.get('kyori_joken')}m]"
            else:
                horse['latest_win_race_lap_6f'] = '(NO DATA)'
                horse['latest_win_race_data'] = '(NO DATA)'
        else:
            horse['latest_win_race_lap_6f'] = '(NO DATA)'
            horse['latest_win_race_data'] = '(NO DATA)'

    # HTMLテーブルの生成を修正
    html = """<div class="race-analysis">
        <div class="table-container">
            <div class="analysis-table">
                <div class="table-header">ラップタイム分析</div>
                <div class="table-body">
                    <div class="row header" style="color: #FFFFFF; font-weight: bold; background-color: #444444;">
                        <div class="cell">馬名</div>
                        <div class="cell">スピード偏差値</div>
                        <div class="cell">スタミナ偏差値</div>
                        <div class="cell">パワー偏差値</div>
                        <div class="cell">直近勝利LAP</div>
                    </div>
    """

    def get_value_color(value):
        if value == '(NO DATA)':
            return '#444444'
        try:
            value_float = float(value)
            if value_float >= 61:
                return '#e95556'  # 赤色
            elif value_float >= 50:
                return '#ee9738'  # オレンジ色
            elif value_float >= 40:
                return '#416cba'  # 青色
            else:
                return '#000000'  # 黒色
        except (ValueError, TypeError):
            return '#444444'

    def get_value_font_size(value):
        if value == '(NO DATA)':
            return '1.0em'
        else:
            return '3.0em'

    final_result = []
    for horse in horses_performance_data:
        speed = horse.get('スピード偏差値')
        stamina = horse.get('スタミナ偏差値')
        power = horse.get('パワー偏差値')

        final_result.append({
            '馬名': horse.get('馬名'),
            'スピード偏差値': speed,
            'スタミナ偏差値': stamina,
            'パワー偏差値': power,
            '直近勝利LAP': horse.get('latest_win_race_lap_6f'),
            '直近勝利レース': horse.get('latest_win_race_data')
        })

        html += f"""
                    <div class="row">
                        <div class="cell" style="font-weight: bold;">{horse.get('馬名')}</div>
                        <div class="cell">
                            <span style="font-size: {get_value_font_size(speed)}; font-weight: bold; color: {get_value_color(speed)}">
                                {speed}
                            </span>
                        </div>
                        <div class="cell">
                            <span style="font-size: {get_value_font_size(stamina)}; font-weight: bold; color: {get_value_color(stamina)}">
                                {stamina}
                            </span>
                        </div>
                        <div class="cell">
                            <span style="font-size: {get_value_font_size(power)}; font-weight: bold; color: {get_value_color(power)}">
                                {power}
                            </span>
                        </div>
                        <div class="cell"><span style="font-size: 1.2em">{horse.get('latest_win_race_lap_6f')}</span><br><span style="font-size: 0.8em; color: #444444;">({horse.get('latest_win_race_data')})</span></div>
                    </div>"""

    html += """
                </div>
            </div>
        </div>
    </div>"""

    # 最後に結果をHTMLで返す
    yield json.dumps({'html': html, 'final_result': final_result}, ensure_ascii=False)

    return final_result  # エージェント用にデータを返す
```

### ポイント
 - スピード・パワー・スタミナの概念と計算の考え方は書籍「安井式上がりXハロン攻略法」を参考に事前計算してから偏差値化しています。
     - <a href="https://books.google.co.jp/books/about/%E5%AE%89%E4%BA%95%E5%BC%8F%E4%B8%8A%E3%81%8C%E3%82%8AX%EF%BE%8A%EF%BE%9B%EF%BE%9D%E6%94%BB%E7%95%A5%E6%B3%95.html?id=yjGnEAAAQBAJ&redir_esc=y">参考リンク</a>
 - ポイントは、画面に表示するデータはHTML形式にしつつ、LLMに渡す用のデータはJSONの配列にしているところです(return final_result)。RAGの分野ですが、<a href="https://arxiv.org/abs/2411.02959">HTMLを知識構造としてそのまま利用することの有用性が示唆されていたりします</a>から、HTMLをそのまま流用してLLMに渡しても良かった気もします。

# ふみの
<table>
<tr>
<td width="200">
板書・書記係：<br><b>板倉ふみの</b>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/dfa8fba8-2405-5151-47d2-ddf0b8aa0d8f.png">
</td>
<td>
引っ込み思案で人見知りな１年生。<br>
分析用の情報や、全員の会話を定期的にまとめてホワイトボードにまとめる役。<br>
ちなみに最初の予想コメントは彼女がしているため結構予想にも影響を及ぼしている、影の主役。<br>
最近の悩みはホワイトボードに要点を書くときに騎手の名前を敬称略にしたいけどゆかりが怒るのが怖くてできないこと。
</td>
</tr>
</table>

## ふみのの思考実装例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/80cdfb10-13dd-ce6e-8660-c2dbdfc6f75b.png)
ふみののメインタスクは各キャラの発言をまとめることです。

## 実装をご紹介

```python
def race_note_summary(model):
    def _race_note_summary(state):
        messages = state.get("messages", [])

        chat_messages = [msg for msg in messages if isinstance(msg, ChatMessage)]        # ChatMessageインスタンスのみを抽出
        latest_chat_message = chat_messages[-1] if chat_messages else None
        latest_role = latest_chat_message.role if latest_chat_message else None

        # 最新のメッセージから遡って同じroleの発言を抽出
        extracted_messages = []
        for msg in reversed(chat_messages):
            if msg.role == latest_role:
                extracted_messages.append(msg)
            else:
                break
        
        # 抽出したメッセージを正しい順序に戻す
        extracted_messages.reverse()

        role_name = convert_role_to_name(latest_role)
        role_account = convert_role_to_account(latest_role)

        # extracted_messagesの各メッセージからHTMLタグを除去
        extracted_messages = [
            ChatMessage(
                content=remove_html_tags(msg.content),
                role=msg.role
            ) for msg in extracted_messages
        ]

        print(f"{role_name}の発言をまとめます:")
        for msg in extracted_messages:
            print(f"- {msg.content[:20]}...")

        horses = get_horse_profile(state.get("active_race"))
        active_horse_id = state.get("active_horse")
        if active_horse_id:
            horse = next((horse for horse in horses if horse['血統登録番号'] == active_horse_id), None)
            active_horse_name = horse.get("馬名", "")
            system_message = f"""
                あなたは公平で客観的な立場の筆記係です。
                {role_account}担当の{role_name}の発言をまとめ、** Markdown形式の箇条書き ** で要約してください。
                # 要約のポイント
                - 「{role_account}」や「{role_name}」という言葉は別途添付するので不要
                - まず見出し（#）をつけ、テーマとする馬名「{active_horse_name}」を必ず記載し、Q&A要素（担当者への質問への回答）とセットで記載
                - 見出し以下の解説は階層ツリー形式（-で始まる箇条書き）で記載
                - セリフ的口調は省き、発言内容を体言止めや記号・略称を活用して記載
                - テーマとなる馬（{active_horse_name}）以外に推奨する馬がいる場合は、見出しとして"# 推奨馬"をつけ、それらの馬名やテーマ馬との比較を記載
                - それらの主張・分析情報を記載しつつ、その根拠データ(数値等)はなるべく短縮して記載
                - 担当領域({role_account})以外の情報は記載しない
                - 馬名は呼び捨てだが、人の名前はかならず「さん」をつける
            """
        else:
            system_message = f"""
                あなたは公平で客観的な立場の筆記係です。
                {role_account}担当の{role_name}の発言をまとめ、** Markdown形式の箇条書き ** で要約してください。
                # 要約のポイント
                - 「{role_account}」や「{role_name}」という言葉は別途添付するので不要
                - 発言内容を階層ツリー形式（-で始まる箇条書き）で記載
                - セリフ的口調は省き、発言内容を体言止めや記号・略称を活用して記載
                - 担当領域({role_account})以外の情報は記載しない
                - 馬名は呼び捨てだが、人の名前はかならず「さん」をつける
            """

        human_message = f"""
            要約対象となる{role_name}の発言:
            {chr(10).join([msg.content for msg in extracted_messages])}
            ** 最大で１０行程度にまとめてください。 **
        """

        response = safe_invoke(model, [
            SystemMessage(content=system_message),
            HumanMessage(content=human_message)
        ])

        summary = response.content

        roles_summary = state.get("roles_summary", {})
        if latest_role in roles_summary:
            roles_summary[latest_role] += f"\n{summary}"
        else:
            roles_summary[latest_role] = summary
        state["roles_summary"] = roles_summary

        # roles_summaryをJSON形式にダンプ
        roles_summary_json = json.dumps(roles_summary, ensure_ascii=False)

        # フロントエンドに送信するWebサイト開く命令を準備
        frontend_data = {
            "type": "open",
            "content": "summary_board",
            "tab_title": f"分析まとめ",
            "roles_summary": roles_summary_json
        }

        text_messages = [
            f"あの…{role_name}さんの{role_account}情報をまとめました…",
            f"えっと…{role_name}さんの{role_account}情報のまとめを作成しました…",
            f"その…{role_name}さんの{role_account}をまとめてみました…",
            f"あっ、{role_name}さんの{role_account}のまとめはこちらです…",
            f"えーと、{role_name}さんの{role_account}情報をまとめてみましたよ…",
            f"では…{role_name}さんの{role_account}情報のまとめを表示します…",
            f"えっと…{role_name}さんの{role_account}情報はこんな感じでしょうか…",
            f"その…{role_name}さんの{role_account}情報の要約結果です…",
            f"あっ、{role_name}さんの{role_account}情報のまとめはこちらです…",
            f"えーと、{role_name}さんの{role_account}情報をまとめました…"
        ]
        text_message = random.choice(text_messages)
       
        return {"messages": [ChatMessage(role=latest_role, content=text_message, command=frontend_data)], "roles_summary": roles_summary}       
    return _race_note_summary
```

### ポイント
 - なぜ書記係が存在するのかというと、チャット形式で情報を蓄積していくと後半になるほどログが長くなり、LLMのinput tokenが長くなりすぎる＝反応遅く・コスト高くなってしまうという問題を緩和するためです。
 - 「会議でも話が長くなると要点を忘れて迷走するので、ホワイトボードにメモしたほうがよい」ということになぞらえて、この要約はホワイトボードと呼んでいます。（LangGraphのscratch padという考え方を転用）
 - LangChainのChatMessageにはroleという形でキャラを書き添えておき、そのroleを条件にチャットログを抽出して、要約させています。
 - 要約はLLMの十八番なのでかなり精度高く要点情報を押さえてくれます。Markdown形式にすることでLLMが読むにも人間がブラウザで読むにも兼用しています。
 - みちるの意思決定ではこのホワイトボード情報を重視しており、なるべく短いinput tokenでインストラクションが強く効くように工夫しています。



# おわり！
これでキャラ実装のご説明は終わりです！
また余裕を見て記事を書ければと思いますので、また！

![thinking.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3937427/f90dca81-8525-7cd9-0d19-c6f5a74c6a76.gif)