3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

どこでもいい、なんでもいいを撲滅したい

3
Posted at

第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回の成果と課題

✅ 達成できたこと

  1. 基本的な議論システムの実装: 3つのAIペルソナが意見を出し、議長が決定
  2. ペルソナシステムの確立: JSON設定による多様なキャラクター定義
  3. 最小限のWeb UI: 議題入力→結果表示の基本フロー
  4. Docker環境: 一発で動作する開発環境

❌ 課題と限界

  1. 単発の意見交換: 真の「議論」になっていない
  2. リアルタイム性の欠如: 結果が出るまで何も見えない
  3. 決定根拠の薄さ: 各ペルソナの意見を並べただけ
  4. UI/UXの貧弱さ: 機能最優先で見た目は二の次

次回予告

第2回では、このシンプルな意見交換システムを「真の議論」に進化させます!

  • マルチラウンド制: 6段階の議論プロセス
  • 質疑応答機能: エージェント同士が質問し合う
  • リアルタイム表示: Socket.IOによる議論の可視化
  • 議長の介入: より深い議論を促すファシリテーション

「どこでもいい」撲滅への道のりは始まったばかりです。次回もお楽しみに!


連載「どこでもいい、なんでもいいを撲滅したい」

  • 第1回: 「どこでもいい」への宣戦布告 ← 今回
  • 第2回: 3人寄れば文殊の知恵 (近日公開)
  • 第3回: 現実世界との接続 (近日公開)
  • 第4回: 顔が見える議論 (近日公開)
  • 第5回: 完成形への道 (近日公開)
3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?