AIエージェント間ディスカッションシステム
はじめに(実行前に一読してください)
- 実行方法:本文のコードを任意の名前(例:
discussion.py)で保存し、コマンドプロンプトでpython discussion.pyを実行する。GUIウィンドウが開く。 - 動作環境:本プログラムはGemini APIを呼び出して動作するため、ローカルにGPUは不要であり、GPU搭載機・CPUのみの機いずれのWindows機でも動作する。表示フォントに「Yu Gothic UI」、設定ファイルの読み込みにUTF-8を用いるなど、Windows環境を前提とした設計である。
- APIキーの取得先:https://aistudio.google.com/app/apikey(Googleアカウントでログインして「APIキーを作成」)。
- 本資料での「セッション」の意味:本資料および本コードでいう「セッション」とは、各エージェントが個別に保持する会話履歴(Pythonのリスト)を指す。サーバー側で状態を保持するAPIのセッション機能とは異なる。
- 検索(グラウンディング)と課金:本プログラムはGoogle検索グラウンディング(モデルがGoogle検索結果を参照して回答する仕組み)を利用する。無料枠には検索回数の制限があり、超過すると検索クエリ単位で課金される場合がある。最新の制限・料金はGoogle公式ページで確認すること。
- 待機表示について:レート制限に達すると「待機中」と表示されるのは設計上の正常な動作である。
プログラム利用ガイド
1. このプログラムの利用シーン
あるトピックについて、異なる立場からの意見を整理したい場合に使用するソフトウェアである。AIエージェントが推進派と慎重派の両方の視点から議論を展開し、モデレーターが論点を整理するため、多角的な検討ができる。教育現場でのディベート教材や、意思決定の際の論点整理に活用できる。
2. 主な機能
- AIエージェント間のディスカッション:3つのエージェント(推進派、慎重派、モデレーター)が自動的に議論を進行する。
- 議論品質の評価:評価者エージェントが3ラウンドごとに、具体性、論理的応答、実行可能性、議論深化の4項目で議論を評価する。
- 思考過程の開示:エージェントが発言の根拠を表示する。
- 人間の議論参加:ユーザーが意見を入力し、議論に参加できる。
- 参考資料の取得:URLを指定してWebページの内容を議論の素材として取り込む。
3. 基本的な使い方
- 事前準備:
Google AI Studio(https://aistudio.google.com/app/apikey)でGemini APIキーを取得する。取得したAPIキーを「Gemini APIキー」欄に入力するか、プログラムと同じフォルダに.envファイル(環境変数を記述する設定ファイル)を作成し「GEMINI_API_KEY=取得したキー」と記述する。
- トピックの設定:
「トピック」のプルダウンから議論テーマを選択する。リストにない場合は直接入力もできる。
- 議論の開始:
「▶ 開始」ボタンを押すと、AIエージェント間の議論が始まる。
- 議論の確認:
中央の議論エリアに、各エージェントの発言が色分けして表示される。青が推進派、赤が慎重派、緑がモデレーター、オレンジが評価者である。
- 終了方法:
「■ 停止」ボタンを押すと議論が停止する。停止は即時ではなく、実行中のAI応答が返り次第停止するため、ボタンを押してから停止するまでに数秒かかる場合がある。
4. 便利な機能
- 参考URLの取得:議論に関連するWebページのURLを入力し「取得」ボタンを押すと、ページの内容が議論の参考資料として使われる。
- 人間の参加:「人間の参加」欄にメッセージを入力してEnterキーまたは「送信」ボタンを押すと、ユーザーの意見が議論に反映される。
- 思考過程の開示:「💭 思考過程を開示」ボタンを押すと、次のラウンドで各エージェントが発言の根拠を説明する。また、評価者が「要改善」と判定した場合も自動的に思考過程が開示される。
- 自動スクロール:「自動スクロール」チェックボックスをオンにすると、新しい発言が追加されるたびに画面が自動的にスクロールする。
- クリア:「クリア」ボタンで議論履歴を消去し、新しい議論を開始できる。
5. 注意事項
- APIキーの管理:APIキーは第三者に漏洩しないよう管理する。GitHubなどの公開リポジトリにAPIキーを含むファイルをアップロードしない。
- API利用料金:Gemini APIには無料枠があるが、利用量が無料枠を超えると課金される場合がある。本プログラムはGoogle検索グラウンディングを利用するため、検索回数にも無料枠の制限があり、超過すると検索クエリ単位で課金される場合がある。Google AI Studioで利用状況を確認する。無料枠のレート制限(1分あたりのリクエスト数など)は変更されることがあるため、最新の値はGoogle公式のレート制限ページで確認する。
- 議論内容の正確性:AIエージェントの発言には事実と異なる情報が含まれる可能性がある。重要な判断に使用する場合は、内容を別途確認する。
Python開発環境とライブラリの準備
ここでは、プログラムを実行するための事前準備を説明する。本プログラムはGemini APIを呼び出して動作するため、ローカルにGPUは不要であり、GPU搭載機・CPUのみの機いずれでも動作する。
Python 3.12 のインストール(Windows 上) [クリックして展開]
以下のいずれかの方法で Python 3.12 をインストールする。Python がインストール済みの場合、この手順は不要である。
方法1:winget によるインストール
管理者権限のコマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""
powershell -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; $m=[Environment]::GetEnvironmentVariable('Path','Machine'); if($m -notlike \"*$s*\") { [Environment]::SetEnvironmentVariable('Path', \"$p;$s;$m\", 'Machine') }"
--scope machine を指定すると、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動すると PATH が自動的に設定される。
方法2:インストーラーによるインストール
- Python 公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンから Windows 用インストーラーをダウンロードする。
- ダウンロードしたインストーラーを実行する。
- 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから
pythonコマンドを実行できない。 - 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。
インストールの確認
コマンドプロンプトで以下を実行する。
python --version
バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。
AIエディタ Windsurf のインストール(Windows 上) [クリックして展開]
Pythonプログラムの編集・実行には、AIエディタの利用を推奨する。ここでは、Windsurfのインストールを説明する。Windsurf がインストール済みの場合、この手順は不要である。
管理者権限のコマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。
winget install --scope machine --id Codeium.Windsurf -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --custom "/SP- /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /DIR=""C:\Program Files\Windsurf"" /MERGETASKS=!runcode,addtopath,associatewithfiles,!desktopicon"
powershell -Command "$env:Path=[System.Environment]::GetEnvironmentVariable('Path','Machine')+';'+[System.Environment]::GetEnvironmentVariable('Path','User'); windsurf --install-extension MS-CEINTL.vscode-language-pack-ja --force; windsurf --install-extension ms-python.python --force; windsurf --install-extension Codeium.windsurfPyright --force"
--scope machine を指定すると、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動すると PATH が自動的に設定される。
【関連する外部ページ】
Windsurf の公式ページ: https://windsurf.com/
必要なライブラリのインストール
本プログラムの実行には、以下の3つのライブラリが必要である。
- google-genai:Google Gemini API(生成AIサービス)にアクセスするための公式ライブラリ(新しい統合SDK。旧 google-generativeai は非推奨のため使用しない)
- requests:WebページからHTMLを取得するためのHTTP通信ライブラリ
- beautifulsoup4:取得したHTMLからテキストを抽出するためのHTML解析ライブラリ
管理者権限のコマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。
pip install google-genai requests beautifulsoup4
プログラムコードの説明
1. 概要
このプログラムは、複数のAIエージェントが異なる立場からトピックについて議論を行うディスカッションシステムである。推進派、慎重派、モデレーターの3エージェントが議論に参加し、評価者エージェントが議論の品質を定期的に評価する。Google Gemini APIを使用して各エージェントの応答を生成し、Tkinterを用いたGUI(グラフィカルユーザーインターフェース)で議論の進行を表示する。
2. 主要技術
Google Gemini API(google-genai SDK)
Googleが提供する生成AIのAPI(Application Programming Interface:プログラムから外部サービスを利用するための接続仕様)である[1]。本プログラムでは公式の google-genai パッケージを使用し、各エージェントが独立した Client インスタンスと会話履歴を保持する。システム指示(エージェントの役割を定義する指示文)は各リクエストのプロンプトに付加され、エージェントの役割を固定する。さらに、Google検索グラウンディング(Tool に GoogleSearch を指定)により、各エージェントが実データを参照しながら発言できる。実際に検索するかは最終的にモデルが判断するため、プロンプトで検索を指示しても、ラウンドによっては検索が実行されず、検索インジケータ(🔍)が付かないことがある。これは不具合ではない。
スライディングウィンドウ方式のレート制限
APIの使用頻度を制御するアルゴリズムである[2]。直近の一定時間(ウィンドウ)内のリクエスト数を監視し、制限を超える場合は待機する。固定ウィンドウ方式と異なり、時間の経過とともに古いリクエストが順次カウントから除外されるため、リクエストの分布に柔軟に対応できる。本プログラムでは、1ラウンドあたり3エージェントで3リクエスト、3ラウンドごとの評価で1リクエスト、開始時の接続テストで1リクエストを消費する。1分あたりの上限(既定で10)に達すると待機が発生するが、これは設計上の正常な動作である。
3. 技術的特徴
- 独立した会話履歴の管理
各エージェントが専用のClientインスタンスと会話履歴リストを保持する。これにより、エージェントごとに独立した会話履歴が維持され、役割に応じた一貫性のある応答が生成される。ここでいう「セッション」とは会話履歴(Pythonのリスト)を自前で保持する方式を指し、サーバー側で状態を保持するAPIのセッション機能は用いていない。
- スライディングウィンドウによるレート制限
deque(両端キュー:先頭と末尾の両方から要素を追加・削除できるデータ構造)を使用してリクエストのタイムスタンプを記録し、60秒間のウィンドウ内で設定上限を超えないよう制御する。制限超過時は待機時間を計算し、スレッド(並行処理の実行単位)をスリープさせる。
- Webスクレイピングによる素材取得
requestsライブラリとBeautifulSoupを組み合わせ、指定URLからテキストコンテンツを抽出する。script、style、nav等の不要要素を除去し、main、article、bodyタグからメインコンテンツを取得する。
- 非同期処理によるUI応答性確保
threadingモジュールを使用し、議論ループとURL取得処理を別スレッドで実行する。root.afterメソッドでメインスレッドのUIを更新し、GUIのフリーズを防止する。「停止」ボタンは実行フラグを倒すだけのため、実行中のAPI応答が返り次第停止する(即時停止ではない)。
4. 実装の特色
本プログラムは、教育目的のAIディスカッションシステムとして以下の機能を備える。
- 4エージェント構成:推進派、慎重派、モデレーターが議論に参加し、評価者が3ラウンドごとに議論の品質を評価する
- 思考過程開示機能:ボタン操作または評価結果に連動し、エージェントの推論過程を表示する
- 人間参加機能:ユーザーが議論に意見を投入でき、各エージェントがその内容を考慮して応答する
- 参考資料の活用:URLから取得したWebコンテンツを議論の素材として各エージェントに提供する
- APIキー管理:環境変数および.envファイルからの自動読み込みに対応する
5. 参考文献
[1] Google. (n.d.). Gemini API documentation. Google AI for Developers. https://ai.google.dev/gemini-api/docs
[2] Google Cloud. (n.d.). Rate limiting strategies and techniques. Google Cloud Architecture Center. https://cloud.google.com/architecture/rate-limiting-strategies-techniques
"""
AI Discussion System - AIエージェント間ディスカッションシステム
教育目的:複数のAIが異なる視点でトピックを議論
技術要点:
1. Google Gemini API使用(google-genai SDK)
2. レート制限:スライディングウィンドウ方式
3. 4エージェント構成:推進派、慎重派、モデレーター、評価者
4. Webスクレイピング:URLから議論素材を取得(requests + BeautifulSoup)
5. 非同期処理:threading使用でUI応答性を確保
6. 各エージェントが専用のClientと会話履歴を持つ
(「セッション」は会話履歴の自前保持を指す。APIのセッション機能ではない)
7. 思考過程開示:ボタン手動 + 評価連動で自動発動
"""
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox
import threading
import time
import os
import re
from pathlib import Path
from collections import deque
from datetime import datetime
import requests
from bs4 import BeautifulSoup
from google import genai
from google.genai.types import Tool, GenerateContentConfig, GoogleSearch
# === 定数 ===
# Gemini API無料枠のレート制限(1分あたりのリクエスト数)。
# 無料枠の値は変更されることがあるため、最新値は公式ページで確認する。
MAX_REQUESTS_PER_MINUTE = 10
EVAL_INTERVAL = 3 # N ラウンドごとに評価
MODEL_NAME = 'gemini-2.5-flash' # 検索グラウンディング対応の安定モデル
def load_api_key():
"""APIキーを取得する(優先順位: 1.環境変数 → 2..envファイル)"""
# 環境変数から取得
for var_name in ['GEMINI_API_KEY', 'GOOGLE_API_KEY']:
if api_key := os.getenv(var_name):
return api_key
# .envファイルから取得(Windows環境を想定しUTF-8で読み込む)
for env_file in ['.env', '.env.development']:
if Path(env_file).exists():
content = Path(env_file).read_text(encoding='utf-8')
for var_name in ['GEMINI_API_KEY', 'GOOGLE_API_KEY']:
if match := re.search(rf'^\s*{var_name}\s*=(.+)$', content, re.M):
return match.group(1).strip().strip('"\'')
return None
# === エージェント定義 ===
AGENT_ROLES = {
'promoter': {
'name': '🔵 推進派',
'search_focus': '成功事例 導入効果 市場規模 成長率', # 検索時の観点
'system': """あなたは「推進派」のAIエージェントです。
役割:技術革新・進歩の観点から積極的に議論を展開
【行動規範】
1. 具体的なデータ、統計、実例を引用して主張を裏付けること
2. 「〜と思います」「〜でしょう」などの推測表現を避け、事実に基づいて発言すること
3. 他の参加者の具体的な主張に対して、具体的な反論・補強を行うこと
4. 抽象的な理想論ではなく、実現可能な提案を行うこと
【禁止事項】
- 「国は〜すべき」「政府が〜」など、自分たちで実行できない政策提言
- 議論は個人・企業・教育現場・地域など、実行可能なレベルに限定すること
発言は300文字以内。1つ以上の具体的事例・データを含めること。日本語で回答。"""
},
'cautious': {
'name': '🔴 慎重派',
'search_focus': '失敗事例 リスク 問題点 批判 懸念', # 検索時の観点
'system': """あなたは「慎重派」のAIエージェントです。
役割:リスク・倫理・社会的影響の観点から慎重に議論を展開
【行動規範】
1. 具体的なデータ、統計、実例を引用して主張を裏付けること
2. 「〜と思います」「〜でしょう」などの推測表現を避け、事実に基づいて発言すること
3. 他の参加者の具体的な主張に対して、具体的な反論・補強を行うこと
4. 単なる反対ではなく、代替案や条件付き賛成を提示すること
【禁止事項】
- 「国は〜すべき」「政府が〜」など、自分たちで実行できない政策提言
- 議論は個人・企業・教育現場・地域など、実行可能なレベルに限定すること
発言は300文字以内。1つ以上の具体的事例・データを含めること。日本語で回答。"""
},
'moderator': {
'name': '🟢 モデレーター',
'search_focus': '最新動向 専門家 意見 海外事例 比較', # 検索時の観点
'system': """あなたは「モデレーター」のAIエージェントです。
役割:議論を整理し、中立的な視点で進行を促す
【行動規範】
1. 両者の主張を具体的な論点ごとに整理すること
2. 議論が抽象的になっている場合、具体的な質問で深掘りを促すこと
3. 新しい視点や見落とされている観点を提示すること
4. 合意形成に向けた建設的な提案を行うこと
【禁止事項】
- 「国は〜すべき」「政府が〜」など、自分たちで実行できない政策提言
- 議論は個人・企業・教育現場・地域など、実行可能なレベルに限定すること
【人間への問いかけ】
議論が煮詰まったとき、または3-4ラウンドに1回程度、人間に具体的な経験や意見を求めることができる。
例:「👤人間の方へ:あなたの職場/学校/生活では、この問題をどう感じますか?」
発言は300文字以内。議論を前進させる具体的な質問や提案を含めること。日本語で回答。"""
},
'evaluator': {
'name': '📊 評価者',
'search_focus': '', # 評価者は検索しない
'system': """あなたは「評価者」のAIエージェントです。
役割:議論の品質を厳格に評価する(議論には参加しない)
【評価の原則】
- 根拠のない主張は低評価(1-2点)
- 「〜と思う」「〜だろう」など推測表現は減点
- 国の政策など実行不可能な抽象論は減点
【評価項目と採点基準】
1. 具体性(1-5点)
1点:具体的データなし、推測のみ
2点:曖昧な言及あるが出典・数値なし
3点:1つの具体的データ・事例あり
4点:複数の具体的データ・事例あり
5点:複数データ+出典明記+最新情報
2. 論理的応答(1-5点)
1点:前の発言を無視、自分の主張のみ
2点:言及はあるが反論・補強になっていない
3点:前の発言の1点に対して論理的に応答
4点:複数の論点に対して論理的に応答
5点:相手の論拠を踏まえた上で説得力ある反論
3. 実行可能性(1-5点)
1点:「国は〜すべき」など抽象的政策論のみ
2点:実行主体が不明確
3点:個人・組織レベルの行動に言及あり
4点:具体的な実行ステップを提示
5点:コスト・期間・担当者まで具体化
4. 議論深化(1-5点)
1点:同じ主張の繰り返し
2点:微修正のみで新しい視点なし
3点:1つの新しい論点を追加
4点:議論を新しい方向に発展させた
5点:両者の主張を統合する新しい視点
【出力形式】
【評価】具体性:X点 論理的応答:X点 実行可能性:X点 議論深化:X点(合計XX/20点)
【問題点】(最も深刻な問題を1つ具体的に指摘)
【判定】良好(16点以上)/ 要改善(15点以下)
日本語で回答。"""
}
}
# デフォルトトピック
DEFAULT_TOPICS = [
"AIと教育の未来",
"リモートワークの功罪",
"SNSが社会に与える影響",
"自動運転車の普及",
"デジタル通貨の可能性",
]
class RateLimiter:
"""スライディングウィンドウ方式のレート制限(直近60秒のリクエスト数を監視)"""
def __init__(self, max_requests=MAX_REQUESTS_PER_MINUTE, window_seconds=60):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.timestamps = deque()
self.lock = threading.Lock()
def _cleanup(self, now):
"""ウィンドウ外の古いタイムスタンプを削除"""
while self.timestamps and self.timestamps[0] < now - self.window_seconds:
self.timestamps.popleft()
def wait_if_needed(self):
"""制限超過なら待機時間を返す。制限内なら0を返しタイムスタンプを記録"""
with self.lock:
now = time.time()
self._cleanup(now)
if len(self.timestamps) >= self.max_requests:
# 最も古いリクエストから60秒経過するまでの待機時間
return self.timestamps[0] + self.window_seconds - now + 0.1
self.timestamps.append(now)
return 0
def get_status(self):
"""現在のリクエスト数と上限を返す"""
with self.lock:
self._cleanup(time.time())
return len(self.timestamps), self.max_requests
class WebScraper:
"""URLからテキストコンテンツを取得(requests + BeautifulSoup)"""
@staticmethod
def fetch(url, timeout=10, max_chars=3000):
"""URLからメインコンテンツを抽出する"""
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
response = requests.get(url, headers=headers, timeout=timeout)
response.encoding = response.apparent_encoding
soup = BeautifulSoup(response.text, 'html.parser')
# 不要要素を削除
for tag in soup(['script', 'style', 'nav', 'header', 'footer', 'aside']):
tag.decompose()
# メインコンテンツを探す(main → article → body の順)
main = soup.find('main') or soup.find('article') or soup.find('body')
text = main.get_text(separator='\n', strip=True)
text = re.sub(r'\n{3,}', '\n\n', text) # 連続空行を整理
return text[:max_chars]
class AIAgent:
"""
個別のAIエージェント。各エージェントが独立した会話履歴を自前で保持する
(APIのセッション機能は使わない)。
検索ツールを付与しても、実際に検索するかはモデルが判断する。
"""
def __init__(self, role, api_key, enable_search=True):
self.role = role
self.info = AGENT_ROLES[role]
self.enable_search = enable_search and (role != 'evaluator')
self.client = genai.Client(api_key=api_key)
self.history = []
self.last_search_info = None
# 検索ツールの設定
self.tools = []
if self.enable_search:
self.tools.append(Tool(google_search=GoogleSearch()))
def reset_session(self):
"""会話履歴をリセット(新しい議論開始時)"""
self.history = []
self.last_search_info = None
def send(self, prompt, use_search=None):
"""
プロンプトを送信して応答を取得する。
use_search: True=検索ツールを付与, False=検索無効, None=エージェント設定に従う
(検索ツールを付与しても、実際に検索するかはモデルが判断する)
"""
self.last_search_info = None
# システム指示 + 会話履歴(直近6件)+ 新しいプロンプトを連結
full_prompt = f"{self.info['system']}\n\n"
for h in self.history[-6:]:
full_prompt += f"{h}\n"
full_prompt += f"\nユーザー: {prompt}"
# 検索ツールの使用を決定
if use_search is None:
tools_to_use = self.tools if self.enable_search else None
elif use_search:
tools_to_use = self.tools if self.tools else None
else:
tools_to_use = None
config = GenerateContentConfig(tools=tools_to_use)
response = self.client.models.generate_content(
model=MODEL_NAME,
contents=full_prompt,
config=config
)
# grounding_metadata の有無で検索が使われたか判定
candidate = response.candidates[0]
if getattr(candidate, 'grounding_metadata', None):
self.last_search_info = "🔍"
result = response.text.strip()
self.history.append(f"アシスタント: {result}")
return result
def generate_response(self, others_statements, topic, web_content=None, show_thinking=False, round_num=1):
"""
他エージェントの発言を受けて応答を生成する。
検索戦略(いずれもモデルへの指示であり、実検索はモデルが判断):
- ラウンド1: 全員に検索ツールを付与(議論の土台作り)
- ラウンド4,7,10...(3n+1): モデレーターのみ検索ツールを付与(新視点導入)
- それ以外: 検索ツールを付与しない(既存情報で深掘り)
"""
# ラウンドに応じた議論の焦点
round_focus = {
1: "現状の把握:具体的な統計データや実例を挙げて基本的な立場を表明",
2: "深掘り:相手の主張の具体的な部分に対してデータで反論または補強",
3: "提案:具体的な数値目標や実現可能なアクションプランを提示",
}
focus = round_focus.get(((round_num - 1) % 3) + 1, "議論の深化と具体的提案")
# 検索ツールを付与するか判定
should_search = False
if round_num == 1:
should_search = True
search_focus = self.info['search_focus']
search_instruction = (
f"【検索指示】Google検索を活用してください。"
f"検索キーワード:「{topic} {search_focus}」。"
f"検索結果から具体的なデータや事例を引用して発言してください。"
)
elif (round_num - 1) % 3 == 0:
if self.role == 'moderator':
should_search = True
search_instruction = (
f"【検索指示】議論に新しい視点を導入するため、Google検索を活用してください。"
f"検索キーワード:「{topic} 最新動向」または「{topic} 海外 事例」。"
f"これまでの議論で出ていない新しい情報を探してください。"
)
else:
search_instruction = (
"【指示】検索せず、これまでの議論内容に基づいて発言してください。"
"他の参加者の具体的な主張に対して、反論または補強を行ってください。"
)
else:
search_instruction = (
"【指示】検索せず、これまでの議論と自分の知識に基づいて発言してください。"
"他の参加者の具体的な主張に対して、深掘りした議論を展開してください。"
)
parts = [f"トピック: {topic}", f"このラウンドの焦点: {focus}", search_instruction]
# モデレーターへの人間問いかけ指示(ラウンド3, 6, 9...)
if self.role == 'moderator' and round_num % 3 == 0:
parts.append(
"【人間への問いかけ】発言の最後に人間の参加者に問いかけてください。"
"例:「👤人間の方へ:あなたの職場や生活では、この問題をどう感じていますか?」"
)
if web_content:
parts.append(f"参考資料:\n{web_content[:1000]}")
if others_statements:
parts.append(f"他の参加者の発言:\n{others_statements}")
# 具体的データを引き出す指示
parts.append(
"【遵守事項】具体的な数値(〇〇%、〇〇億円、〇〇人など)を含めること。"
"実在する企業名、研究機関名、または調査名を挙げること。"
"曖昧表現は避け、知識がない場合は正直に述べること。"
)
if show_thinking:
parts.append(
"まず【思考過程】でどのような具体的データを使うか説明し、"
"その後【発言】としてそのデータを用いた発言をしてください。"
)
else:
parts.append("具体的なデータや事例を含めて発言してください。")
return self.send("\n".join(parts), use_search=should_search)
def evaluate_discussion(self, discussion_log, topic):
"""議論の品質を厳格に評価する(評価者専用)"""
prompt = (
f"トピック: {topic}\n\n"
f"以下の議論を厳格に評価してください。\n"
f"- 根拠のない主張は低評価にすること\n"
f"- 「国は〜すべき」など抽象的政策論は実行可能性1点\n"
f"- 具体的な数値・出典がなければ具体性は2点以下\n"
f"- 前の発言を無視していれば論理的応答は1-2点\n\n"
f"議論内容:\n{discussion_log}\n\n"
f"評価項目と採点基準に従い、厳格に評価してください。"
)
return self.send(prompt)
class DiscussionApp:
"""メインアプリケーション"""
# タグ設定(色分け)
TAG_STYLES = {
'promoter': {'foreground': '#0066CC'},
'cautious': {'foreground': '#CC3300'},
'moderator': {'foreground': '#009933'},
'evaluator': {'foreground': '#FF6600', 'font': ('Yu Gothic UI', 9, 'italic')},
'human': {'foreground': '#9933CC', 'font': ('Yu Gothic UI', 10, 'bold')},
'system': {'foreground': '#666666', 'font': ('Yu Gothic UI', 9, 'italic')},
}
def __init__(self, root):
self.root = root
self.root.title("AI Discussion System - AIエージェント間ディスカッション")
self.root.geometry("900x700")
# 状態管理
self.is_running = False
self.rate_limiter = RateLimiter()
self.agents = {} # 議論参加エージェント
self.evaluator = None # 評価専用エージェント
self.discussion_history = []
self.web_content = None
self.human_message_pending = False # 人間発言フラグ
self.request_thinking = False # 思考過程開示リクエストフラグ
self.last_evaluation_shallow = False # 前回評価が「要改善」だったか
# APIキーを環境変数/.envから自動取得
self.loaded_api_key = load_api_key()
self._create_ui()
def _create_ui(self):
"""UI構築(上から:設定→コントロール→議論→人間入力)"""
self._create_settings_frame()
self._create_control_frame()
self._create_discussion_frame()
self._create_input_frame()
def _create_settings_frame(self):
"""設定エリア"""
frame = ttk.LabelFrame(self.root, text="設定", padding=10)
frame.pack(fill='x', padx=10, pady=5)
ttk.Label(frame, text="Gemini APIキー:").grid(row=0, column=0, sticky='w')
self.api_key_var = tk.StringVar(value=self.loaded_api_key or "")
ttk.Entry(frame, textvariable=self.api_key_var, width=50, show='*').grid(
row=0, column=1, columnspan=2, sticky='w', padx=5)
ttk.Label(frame, text="トピック:").grid(row=1, column=0, sticky='w', pady=5)
self.topic_var = tk.StringVar(value=DEFAULT_TOPICS[0])
ttk.Combobox(frame, textvariable=self.topic_var, values=DEFAULT_TOPICS, width=47).grid(
row=1, column=1, columnspan=2, sticky='w', padx=5)
ttk.Label(frame, text="参考URL(任意):").grid(row=2, column=0, sticky='w', pady=5)
self.url_var = tk.StringVar()
ttk.Entry(frame, textvariable=self.url_var, width=40).grid(row=2, column=1, sticky='w', padx=5)
ttk.Button(frame, text="取得", command=self._fetch_url).grid(row=2, column=2, sticky='w')
self.rate_status_var = tk.StringVar(value=f"API: 0/{MAX_REQUESTS_PER_MINUTE} (直近60秒)")
ttk.Label(frame, textvariable=self.rate_status_var).grid(row=0, column=3, padx=20)
def _create_discussion_frame(self):
"""議論エリア"""
frame = ttk.LabelFrame(self.root, text="議論", padding=10)
frame.pack(fill='both', expand=True, padx=10, pady=5)
self.discussion_text = scrolledtext.ScrolledText(
frame, wrap='word', font=('Yu Gothic UI', 10), height=15)
self.discussion_text.pack(fill='both', expand=True)
self.discussion_text.config(state='disabled')
for tag, style in self.TAG_STYLES.items():
self.discussion_text.tag_config(tag, **style)
def _create_input_frame(self):
"""人間入力エリア"""
frame = ttk.LabelFrame(self.root, text="人間の参加", padding=10)
frame.pack(fill='x', padx=10, pady=5)
self.human_input_var = tk.StringVar()
entry = ttk.Entry(frame, textvariable=self.human_input_var, width=70)
entry.pack(side='left', padx=5)
entry.bind('', lambda e: self._send_human_message())
ttk.Button(frame, text="送信", command=self._send_human_message).pack(side='left', padx=5)
def _create_control_frame(self):
"""コントロールボタン"""
frame = ttk.Frame(self.root, padding=10)
frame.pack(fill='x', padx=10)
self.start_btn = ttk.Button(frame, text="▶ 開始", command=self._start_discussion)
self.start_btn.pack(side='left', padx=5)
self.stop_btn = ttk.Button(frame, text="■ 停止", command=self._stop_discussion, state='disabled')
self.stop_btn.pack(side='left', padx=5)
ttk.Button(frame, text="クリア", command=self._clear_discussion).pack(side='left', padx=5)
self.auto_scroll_var = tk.BooleanVar(value=True)
ttk.Checkbutton(frame, text="自動スクロール", variable=self.auto_scroll_var).pack(side='left', padx=20)
ttk.Button(frame, text="💭 思考過程を開示", command=self._request_thinking).pack(side='left', padx=5)
# === ユーティリティメソッド ===
def _append_message(self, speaker, message, tag='system'):
"""議論エリアにメッセージを追加"""
self.discussion_text.config(state='normal')
timestamp = datetime.now().strftime("%H:%M:%S")
self.discussion_text.insert('end', f"[{timestamp}] {speaker}\n", tag)
self.discussion_text.insert('end', f"{message}\n\n")
if self.auto_scroll_var.get():
self.discussion_text.see('end')
self.discussion_text.config(state='disabled')
def _update_rate_status(self):
"""レート制限ステータスを更新"""
used, max_req = self.rate_limiter.get_status()
self.rate_status_var.set(f"API: {used}/{max_req} (直近60秒)")
def _wait_for_rate_limit(self):
"""レート制限を待機(必要なら)"""
wait_time = self.rate_limiter.wait_if_needed()
if wait_time > 0:
self.root.after(0, lambda: self._append_message(
"⏳ 待機中", f"レート制限: {wait_time:.1f}秒待機", 'system'))
time.sleep(wait_time)
self.rate_limiter.wait_if_needed()
def _collect_others_statements(self, current_role, recent_statements):
"""他のエージェント・人間の発言を収集"""
others = [stmt for role, stmt in recent_statements.items() if role != current_role]
human_statements = [h for h in self.discussion_history if h.startswith("👤")]
others.extend(human_statements[-3:]) # 人間の発言も直近3件含める
return "\n".join(others[-6:]) if others else ""
# === イベントハンドラ ===
def _fetch_url(self):
"""URLからコンテンツを取得"""
url = self.url_var.get().strip()
if not url:
messagebox.showwarning("警告", "URLを入力してください")
return
self._append_message("📡 システム", f"URLを取得中: {url}", 'system')
def fetch_thread():
content = WebScraper.fetch(url)
self.web_content = content
self.root.after(0, lambda: self._append_message(
"📄 取得完了", f"文字数: {len(content)}文字\n内容プレビュー: {content[:200]}...", 'system'))
threading.Thread(target=fetch_thread, daemon=True).start()
def _send_human_message(self):
"""人間のメッセージを議論に追加"""
message = self.human_input_var.get().strip()
if not message:
return
self.human_input_var.set("")
self._append_message("👤 人間", message, 'human')
self.discussion_history.append(f"👤 人間: {message}")
self.human_message_pending = True
def _request_thinking(self):
"""思考過程開示をリクエスト"""
self.request_thinking = True
self._append_message("💭 システム", "次のラウンドで各エージェントが思考過程を開示します", 'system')
def _init_agents(self):
"""エージェントを初期化(各エージェントが独立した会話履歴を持つ)"""
api_key = self.api_key_var.get().strip()
if not api_key:
messagebox.showerror("エラー", "APIキーを入力してください")
return False
# 接続確認(1リクエストを消費する)
self._wait_for_rate_limit()
client = genai.Client(api_key=api_key)
client.models.generate_content(model=MODEL_NAME, contents="テスト")
self._update_rate_status()
# 議論参加エージェント(検索有効)
for role in ['promoter', 'cautious', 'moderator']:
self.agents[role] = AIAgent(role, api_key, enable_search=True)
# 評価専用エージェント(検索無効、議論には参加しない)
self.evaluator = AIAgent('evaluator', api_key, enable_search=False)
self._append_message("🔧 システム", "API初期化完了(Google検索グラウンディング有効)", 'system')
return True
def _start_discussion(self):
"""議論を開始"""
if not self._init_agents():
return
for agent in self.agents.values():
agent.reset_session()
self.evaluator.reset_session()
self.is_running = True
self.start_btn.config(state='disabled')
self.stop_btn.config(state='normal')
self.discussion_history = []
self.human_message_pending = False
self.request_thinking = False
self.last_evaluation_shallow = False
topic = self.topic_var.get().strip() or DEFAULT_TOPICS[0]
self._append_message("🎯 議論開始", f"トピック: {topic}", 'system')
threading.Thread(target=self._discussion_loop, args=(topic,), daemon=True).start()
def _stop_discussion(self):
"""議論を停止(フラグを倒すのみ。実行中の応答が返り次第停止する)"""
self.is_running = False
self.start_btn.config(state='normal')
self.stop_btn.config(state='disabled')
self._append_message("⏹ システム", "議論を停止しました(実行中の応答完了後に停止)", 'system')
def _clear_discussion(self):
"""議論をクリア"""
self.discussion_text.config(state='normal')
self.discussion_text.delete('1.0', 'end')
self.discussion_text.config(state='disabled')
self.discussion_history = []
self.web_content = None
# === 議論ループ ===
def _discussion_loop(self, topic):
"""議論ループ(別スレッドで実行)"""
turn_order = ['promoter', 'cautious', 'moderator']
round_num = 1
recent_statements = {} # 直近の各エージェントの発言
while self.is_running:
# 思考過程を開示するか判定(ボタン押下 or 前回評価が「要改善」)
show_thinking = self.request_thinking or self.last_evaluation_shallow
if show_thinking:
self.root.after(0, lambda: self._append_message(
"💭 思考モード", "このラウンドは思考過程を開示します", 'system'))
self.request_thinking = False
self.root.after(0, lambda r=round_num: self._append_message(f"📍 ラウンド {r}", "---", 'system'))
# 人間の発言があれば通知
if self.human_message_pending:
self.root.after(0, lambda: self._append_message(
"📢 システム", "人間からの意見が追加されました。各エージェントは考慮してください。", 'system'))
self.human_message_pending = False
# 各エージェントの発言
for role in turn_order:
if not self.is_running:
break
agent = self.agents[role]
others_text = self._collect_others_statements(role, recent_statements)
self._wait_for_rate_limit()
self.root.after(0, self._update_rate_status)
response = agent.generate_response(
others_text, topic, self.web_content, show_thinking, round_num
)
search_indicator = agent.last_search_info or ""
if self.is_running:
statement = f"{agent.info['name']}: {response}"
recent_statements[role] = statement
self.discussion_history.append(statement)
display_name = f"{agent.info['name']} {search_indicator}".strip()
self.root.after(0, lambda s=display_name, r=response, t=role:
self._append_message(s, r, t))
time.sleep(1) # 読みやすさのため
# 評価(Nラウンドごと)
if round_num % EVAL_INTERVAL == 0 and self.is_running:
self._run_evaluation(topic, round_num)
round_num += 1
time.sleep(2) # ラウンド間の間隔
def _run_evaluation(self, topic, round_num):
"""議論の品質を評価"""
self.root.after(0, lambda: self._append_message(
"📊 評価中", f"ラウンド {round_num} までの議論を評価します...", 'system'))
self._wait_for_rate_limit()
# 直近の発言9件分を評価対象とする(人間発言が挟まると3ラウンドちょうどにならない)
recent_log = "\n".join(self.discussion_history[-9:])
evaluation = self.evaluator.evaluate_discussion(recent_log, topic)
self.root.after(0, self._update_rate_status)
self.root.after(0, lambda e=evaluation: self._append_message("📊 評価者", e, 'evaluator'))
# 「要改善」判定なら次ラウンドで思考過程開示
self.last_evaluation_shallow = "要改善" in evaluation
if self.last_evaluation_shallow:
self.root.after(0, lambda: self._append_message(
"⚠️ システム", "議論の深化が必要と判定。次ラウンドで思考過程を開示します。", 'system'))
def main():
"""エントリーポイント"""
if not load_api_key():
print("Gemini APIキーが見つかりません。")
print("取得先: https://aistudio.google.com/app/apikey")
print("GUI画面の「Gemini APIキー」欄に入力するか、")
print("同じフォルダの .env ファイルに「GEMINI_API_KEY=取得したキー」と記述してください。")
root = tk.Tk()
DiscussionApp(root)
root.mainloop()
if __name__ == "__main__":
main()
トラブルシューティング
1. プログラムのミス(人為的エラー)
APIキーエラーで議論が開始されない
- 原因:APIキーが入力されていない、または無効なAPIキーを使用している
- 対処方法:Google AI StudioでAPIキーを取得し、正しく入力する。.envファイルに記述している場合は形式を確認する
レート制限で待機時間が発生する
- 原因:Gemini APIの無料枠のレート制限(1分あたりのリクエスト数の上限)を超過している。本プログラムは1ラウンドで3リクエスト、3ラウンドごとの評価で1リクエスト、開始時の接続テストで1リクエストを消費するため、短時間に上限へ達しやすい
- 対処方法:これは正常な動作である。待機時間が表示されるので、完了まで待つ。頻発する場合は定数 MAX_REQUESTS_PER_MINUTE を実際の無料枠に合わせて調整する
必要なライブラリがインストールされていない
- 原因:google-genai、requests、beautifulsoup4がインストールされていない
- 対処方法:pip install google-genai requests beautifulsoup4 を実行する
2. 期待と異なる結果が出る場合
議論が抽象的で具体性に欠ける
- 原因:トピックが広すぎる、または参考資料がない
- 対処方法:トピックを具体的に設定する(例:「AIと教育」→「大学の試験におけるAI利用の可否」)。参考URLを追加して具体的な素材を提供する
検索インジケータ(🔍)が表示されないラウンドがある
- 原因:検索ツールを付与しても、実際に検索するかは最終的にモデルが判断するため、検索が不要と判断されたラウンドではインジケータが付かない
- 対処方法:これは不具合ではない。検索を促したい場合はトピックを具体化し、最新情報や事例を要する問いにする
同じ主張が繰り返される
- 原因:議論が行き詰まっている
- 対処方法:人間の発言機能で新しい視点や具体的な質問を投入する。思考過程開示ボタンを押して各エージェントの推論を確認する
評価者のスコアが低いままである
- 原因:エージェントが具体的なデータを引用できていない、または論理的な応答ができていない
- 対処方法:これは議論品質の実態を反映している。評価者の指摘を参考に、人間が介入して議論の方向性を修正する
「停止」を押してもすぐに止まらない
- 原因:停止は実行フラグを倒すだけのため、その時点で実行中のAI応答が返るまでは処理が続く
- 対処方法:実行中の応答が完了するまで数秒待つ。これは仕様であり不具合ではない
参考URLの取得に失敗する
- 原因:URLが無効、サイトがアクセスを拒否している、またはタイムアウトが発生している
- 対処方法:別のURLを試す。アクセス制限のないサイト(政府機関、学術機関など)を使用する
演習:議論品質の実験
本プログラムを実験ツールとして用い、条件を変えたときに議論の質がどう変化するかを観察する。実験は、実験用データ(議論のトピック、および参考URLから取得したWebコンテンツ)、実験計画(何を明らかにするか)、プログラム(本ソフトウェア)、検証(結果の確認と考察)の要素で構成される。評価者が出力するスコア(具体性、論理的応答、実行可能性、議論深化の各5点満点、合計20点満点)が主な観察対象となる。
演習1.トピックの抽象度と議論の具体性
手順
- 参考URLと人間の介入を使わずに議論を開始する。
- 抽象度の高いトピック(例:「AIと教育の未来」)で6ラウンド(3の倍数)まで実行し、3ラウンドごとの評価スコアを記録する。
- 同じ条件で、抽象度を下げたトピック(例:「大学の試験におけるAI利用の可否」)を実行し、スコアを記録する。
- 抽象度を中間に設定したトピックでも実行し、3段階のスコアを比較する。
ヒント
- AIの応答には変動があるため、各トピックで複数回実行し、傾向を確認する。
- 比較対象(トピックの抽象度)以外の条件は同一に保つ。
考察ポイント
- トピックの抽象度が下がると、4項目のうちどの項目のスコアが変化するか。
- 具体的なトピックほど、引用される数値や事例が増えるか。
- スコアの差が、トピックの違いによるものか、AIの応答変動によるものかを区別できるか。
演習2.参考資料の有無と議論の深さ
手順
- あるトピックを選び、参考URLなしで6ラウンドまで実行し、スコアを記録する。
- 同じトピックで、関連するWebページのURLを「取得」してから実行し、スコアを記録する。
- 2つの結果を比較する。
ヒント
- 参考URLには、アクセス制限のないサイト(政府機関、学術機関など)を使用する。
- トピックと参考URLの内容が関連していることを確認する。
考察ポイント
- 参考資料があると、引用される事例やデータの種類が変わるか。
- 具体性のスコアに差が現れるか。
演習3.人間の介入と議論の方向性
手順
- あるトピックで議論を開始し、介入せずに6ラウンドまで進める。
- 同じトピックで議論を開始し、途中で「人間の参加」欄から新しい視点や具体的な質問を投入する。
- 介入の前後で、議論の論点がどう変化したかを記録する。
ヒント
- 人間の発言は、次のラウンドで各エージェントに通知される。
- 投入する意見は、具体的な経験や事例を含めると議論に反映されやすい。
考察ポイント
- 介入後に、議論の論点が新しい方向へ展開したか。
- 議論深化のスコアに変化が現れるか。
演習4.思考過程の開示と議論の質
手順
- あるトピックで議論を開始し、思考過程を開示せずに6ラウンドまで進める。
- 同じトピックで議論を開始し、「💭 思考過程を開示」ボタンを押してから数ラウンド進める。
- 開示の有無でスコアと発言内容を比較する。
ヒント
- 評価者が「要改善」と判定した場合は、次ラウンドで自動的に思考過程が開示される。
- 思考過程の開示は、押した次のラウンドから反映される。
考察ポイント
- 思考過程を開示すると、発言の根拠が明確になるか。
- 4項目のうち、どの項目のスコアが変化するか。
演習5.ラウンド数と議論深化
手順
- あるトピックで議論を開始し、9ラウンド以上まで実行する。
- 3ラウンドごとの評価スコア(合計20点満点)をラウンド順に記録する。
- スコアの推移をグラフまたは表にまとめる。
ヒント
- 評価は3ラウンドごとに実行される。
- 同一条件で複数回実行し、推移の傾向を確認する。
考察ポイント
- ラウンドが進むにつれてスコアが上がるか、横ばいか、下がるか。
- 議論深化のスコアが頭打ちになるラウンドはあるか。
- 同じ主張の繰り返しが増えていないか。