第1回: 「どこでもいい」への宣戦布告 - AI議論システムの構想
はじめに - 決断疲れの日常
「今日のランチ、どこにする?」
「どこでもいいよ」
「じゃあ○○は?」
「う〜ん、それはちょっと...」
この無限ループ、経験ありませんか?
現代人は1日に約35,000回もの決断を迫られると言われています。些細な選択でも積み重なると「決断疲れ」を引き起こし、最終的に「どこでもいい」「なんでもいい」という思考停止状態に陥ってしまいます。
そこで考えました。「じゃあAIキャラクターたちに議論してもらって、代わりに決めてもらえばいいじゃない」
これが「NekoMimi Council」プロジェクトの始まりです。
システム構想 - AIたちの民主的議論
基本コンセプト
ユーザー入力(議題 + 選択肢)
↓
複数のAIペルソナが議論
↓
議長AIが最終判断
↓
理由付きの決定を提示
技術スタック選定
早期検証を重視してシンプルな構成にしました:
- バックエンド: FastAPI (高速なAPI開発)
- フロントエンド: Next.js 14 (TypeScript対応)
- リアルタイム通信: Socket.IO (議論の可視化)
- AI: OpenAI GPT + Anthropic Claude (多様性確保)
- 開発環境: Docker Compose (環境統一)
第1歩: MVPの実装
1. ペルソナシステムの設計
まずはAIキャラクターの「個性」を定義する必要があります。
// data/personas/personas.json
[
{
"id": "gourmet",
"name": "美食家マリア",
"persona": "高級料理と本格的な味を重視する美食家。品質にこだわりがあり、多少値段が高くても美味しいものを選ぶ傾向がある。",
"speech_style": "丁寧語で、料理の専門用語を使いながら話す",
"weights": {
"taste": 0.7,
"price": 0.1,
"atmosphere": 0.1,
"convenience": 0.1
}
},
{
"id": "budget",
"name": "節約家タケシ",
"persona": "コスパを最重視する実用主義者。安くて量の多い食事を好み、無駄遣いを嫌う。",
"speech_style": "関西弁で親しみやすく、値段の話をよくする",
"weights": {
"price": 0.6,
"quantity": 0.2,
"taste": 0.1,
"convenience": 0.1
}
}
]
2. 基本的なエージェントクラス
# backend/app/agents/base.py
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List
from ..models.debate import Persona, AgentMessage
class BaseAgent(ABC):
def __init__(self, agent_id: str, agent_name: str, provider: AIProvider):
self.agent_id = agent_id
self.agent_name = agent_name
self.provider = provider
@abstractmethod
async def generate_response(self, topic: str, options: List[str]) -> AgentMessage:
pass
class DebateAgent(BaseAgent):
def __init__(self, persona: Persona, provider: AIProvider):
super().__init__(
agent_id=persona.id,
agent_name=persona.name,
provider=provider
)
self.persona = persona
async def generate_response(self, topic: str, options: List[str]) -> AgentMessage:
"""ペルソナに基づいた意見を生成"""
try:
prompt = self._build_prompt(topic, options)
response = await self._generate_with_retry(
prompt=prompt,
max_tokens=128,
temperature=0.7
)
parsed = self._parse_json_response(response)
message = parsed.get('message', '')
choice = parsed.get('choice', '')
return self._create_agent_message(
message,
choice,
message_type="initial_opinion"
)
except Exception as e:
# フォールバック応答
return self._create_agent_message(
f"申し訳ありません、技術的な問題で意見を述べることができません。",
options[0] if options else None,
message_type="initial_opinion"
)
def _build_prompt(self, topic: str, options: List[str]) -> str:
"""ペルソナに基づいたプロンプト生成"""
options_str = "、".join(options)
return f'''あなたは「{self.persona.name}」として議論に参加してください。
キャラクター設定:
- 名前: {self.persona.name}
- 性格: {self.persona.persona}
- 話し方: {self.persona.speech_style}
- 重視する要素: {json.dumps(self.persona.weights, ensure_ascii=False)}
議題: {topic}
選択肢: {options_str}
以下のJSON形式で回答してください:
{{
"message": "あなたの意見や理由を100文字程度で述べてください。キャラクターの性格と話し方を反映させてください。",
"choice": "選択肢の中から1つを選んでください"
}}
キャラクターになりきって、自然で個性的な回答をしてください。'''
3. 基本的なAPIエンドポイント
# backend/app/api/debate.py
from fastapi import APIRouter, BackgroundTasks
import uuid
import asyncio
router = APIRouter()
debates: Dict[str, DebateResult] = {} # In-memory storage
@router.post("/debate", response_model=DebateStartResponse)
async def start_debate(request: DebateRequest, background_tasks: BackgroundTasks):
"""新しい議論セッションを開始"""
debate_id = str(uuid.uuid4())
# 初期議論結果を作成
debate_result = DebateResult(
id=debate_id,
topic=request.topic,
options=request.options,
status="started",
created_at=datetime.utcnow()
)
debates[debate_id] = debate_result
# バックグラウンドで議論プロセスを開始
background_tasks.add_task(run_simple_debate, debate_id, request.topic, request.options)
return DebateStartResponse(id=debate_id)
async def run_simple_debate(debate_id: str, topic: str, options: list[str]):
"""シンプルな議論プロセス(第1回版)"""
try:
# ペルソナを読み込み
personas = await load_personas(3)
# エージェント作成
debate_agents = []
for persona in personas:
provider = AIProviderFactory.get_default_provider("debate")
agent = DebateAgent(persona, provider)
debate_agents.append(agent)
# 各エージェントが意見を生成
tasks = []
for agent in debate_agents:
task = agent.generate_response(topic, options)
tasks.append(task)
messages = await asyncio.gather(*tasks, return_exceptions=True)
# 議長が最終決定
officer_provider = AIProviderFactory.get_default_provider("officer")
officer = OfficerAgent(officer_provider)
decision = await officer.generate_decision(topic, options, messages)
# 結果を保存
debates[debate_id].status = "completed"
debates[debate_id].final_choice = decision["final_choice"]
debates[debate_id].summary = decision["summary"]
debates[debate_id].confidence = decision["confidence"]
except Exception as e:
debates[debate_id].status = "failed"
logger.error(f"Debate {debate_id} failed: {str(e)}")
4. フロントエンド基本構造
// frontend/src/pages/playground.tsx
import { useState, useCallback } from 'react'
import { DebateForm } from '../components/DebateForm'
import { ChatFeed } from '../components/ChatFeed'
export default function Playground() {
const [result, setResult] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const handleDebateSubmit = useCallback(async (topic: string, options: string[]) => {
setIsLoading(true)
try {
const response = await fetch('/api/debate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, options })
})
const data = await response.json()
// ポーリングで結果を取得(この段階では簡単な実装)
const checkResult = async () => {
const resultResponse = await fetch(`/api/debate/${data.id}`)
const result = await resultResponse.json()
if (result.status === 'completed') {
setResult(result)
setIsLoading(false)
} else if (result.status === 'failed') {
setIsLoading(false)
} else {
setTimeout(checkResult, 1000)
}
}
checkResult()
} catch (error) {
console.error('Debate failed:', error)
setIsLoading(false)
}
}, [])
return (
<div className="playground">
<h1>🐱 NekoMimi Council</h1>
<p>AIキャラクターたちが議論して決めてくれるシステム</p>
<DebateForm onSubmit={handleDebateSubmit} isLoading={isLoading} />
{result && (
<div className="result-card">
<h3>決定: {result.final_choice}</h3>
<p>{result.summary}</p>
<p>信頼度: {(result.confidence * 100).toFixed(0)}%</p>
</div>
)}
</div>
)
}
初期動作確認
CLIでのテスト
$ python scripts/run_cli_poc.py \
--topic "今日のランチどこ行く?" \
--options "寿司屋A, ラーメン店B, カフェC"
# 出力例:
{
"final_choice": "寿司屋A",
"summary": "美食家マリアの味重視の評価と、節約家タケシのコスパ分析、健康志向ユリの栄養面での検討を総合し、品質と満足度のバランスが最も優れている寿司屋Aを選択しました。",
"confidence": 0.82
}
Docker環境での起動
# docker-compose.yml
version: '3.8'
services:
api:
build: ./docker/api
ports: ["8001:8000"]
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
volumes:
- ./backend:/app/backend
- ./data:/app/data
ui:
build: ./docker/frontend
ports: ["3000:3000"]
volumes:
- ./frontend:/app/frontend
$ docker compose up --build
# http://localhost:3000/playground でアクセス
第1回の成果と課題
✅ 達成できたこと
- 基本的な議論システムの実装: 3つのAIペルソナが意見を出し、議長が決定
- ペルソナシステムの確立: JSON設定による多様なキャラクター定義
- 最小限のWeb UI: 議題入力→結果表示の基本フロー
- Docker環境: 一発で動作する開発環境
❌ 課題と限界
- 単発の意見交換: 真の「議論」になっていない
- リアルタイム性の欠如: 結果が出るまで何も見えない
- 決定根拠の薄さ: 各ペルソナの意見を並べただけ
- UI/UXの貧弱さ: 機能最優先で見た目は二の次
次回予告
第2回では、このシンプルな意見交換システムを「真の議論」に進化させます!
- マルチラウンド制: 6段階の議論プロセス
- 質疑応答機能: エージェント同士が質問し合う
- リアルタイム表示: Socket.IOによる議論の可視化
- 議長の介入: より深い議論を促すファシリテーション
「どこでもいい」撲滅への道のりは始まったばかりです。次回もお楽しみに!
連載「どこでもいい、なんでもいいを撲滅したい」
- 第1回: 「どこでもいい」への宣戦布告 ← 今回
- 第2回: 3人寄れば文殊の知恵 (近日公開)
- 第3回: 現実世界との接続 (近日公開)
- 第4回: 顔が見える議論 (近日公開)
- 第5回: 完成形への道 (近日公開)