Agent-UN / scripts /run_motion.py
danielrosehill's picture
Add Gradio visualization for UN Gaza ceasefire resolution simulation
8c1f582
#!/usr/bin/env python3
"""
UN Motion Simulation Runner
This script runs a UN motion simulation where AI agents representing different countries
vote on resolutions and provide statements explaining their positions.
Usage:
python scripts/run_motion.py <motion_id> [--provider cloud|local] [--model MODEL_NAME]
Example:
python scripts/run_motion.py 01_gaza_ceasefire_resolution --provider cloud
"""
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
import re
# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
class MotionRunner:
"""Runs UN motion simulations with AI agents"""
VALID_VOTES = ["yes", "no", "abstain"]
def __init__(self, provider: str = "cloud", model: Optional[str] = None):
"""
Initialize the motion runner
Args:
provider: Either 'cloud' (API) or 'local' (local model)
model: Model name/identifier (optional, uses defaults if not specified)
"""
self.provider = provider
self.model = model
self.project_root = PROJECT_ROOT
self.agents_dir = self.project_root / "agents" / "representatives"
self.motions_dir = self.project_root / "tasks" / "motions"
self.results_dir = self.project_root / "tasks" / "reactions"
# Load configuration
self._load_config()
# Initialize AI client based on provider
self._init_ai_client()
def _load_config(self):
"""Load configuration from environment variables"""
from dotenv import load_dotenv
load_dotenv()
if self.provider == "cloud":
self.anthropic_api_key = os.getenv("ANTHROPIC_API_KEY")
self.openai_api_key = os.getenv("OPENAI_API_KEY")
self.api_base = os.getenv("API_BASE_URL")
if not self.model:
self.model = os.getenv("MODEL_NAME", "gpt-4")
# Determine which API to use based on model name
self.use_anthropic = self.model.startswith("claude")
else:
# Local model configuration
self.local_model_path = os.getenv("LOCAL_MODEL_PATH")
if not self.model:
self.model = os.getenv("LOCAL_MODEL_NAME", "llama3")
self.use_anthropic = False
def _init_ai_client(self):
"""Initialize the appropriate AI client"""
if self.provider == "cloud":
if self.use_anthropic:
try:
import anthropic
self.client = anthropic.Anthropic(api_key=self.anthropic_api_key)
print(f"βœ“ Initialized Anthropic API client (model: {self.model})")
except ImportError:
print("Error: anthropic package not installed. Run: pip install anthropic")
sys.exit(1)
else:
try:
# OpenAI or OpenRouter
import openai
if self.api_base:
self.client = openai.OpenAI(
api_key=self.openai_api_key,
base_url=self.api_base
)
else:
self.client = openai.OpenAI(api_key=self.openai_api_key)
print(f"βœ“ Initialized OpenAI API client (model: {self.model})")
except ImportError:
print("Error: openai package not installed. Run: pip install openai")
sys.exit(1)
else:
try:
# Use Ollama for local models
import ollama
self.client = ollama
print(f"βœ“ Initialized local model client (model: {self.model})")
except ImportError:
print("Error: ollama package not installed. Run: pip install ollama")
sys.exit(1)
def get_country_list(self) -> List[Dict[str, str]]:
"""Get list of all countries with agents"""
countries = []
for country_dir in sorted(self.agents_dir.iterdir()):
if country_dir.is_dir():
system_prompt_path = country_dir / "system-prompt.md"
if system_prompt_path.exists():
country_name = country_dir.name.replace("-", " ").title()
countries.append({
"name": country_name,
"slug": country_dir.name,
"prompt_path": str(system_prompt_path)
})
return countries
def load_motion(self, motion_id: str) -> Dict:
"""Load motion text from file"""
motion_path = self.motions_dir / f"{motion_id}.md"
if not motion_path.exists():
raise FileNotFoundError(f"Motion not found: {motion_path}")
with open(motion_path, 'r', encoding='utf-8') as f:
motion_text = f.read()
return {
"id": motion_id,
"text": motion_text,
"path": str(motion_path)
}
def load_agent_prompt(self, prompt_path: str) -> str:
"""Load agent system prompt"""
with open(prompt_path, 'r', encoding='utf-8') as f:
return f.read()
def query_agent(self, country: Dict, motion: Dict) -> Dict:
"""
Query an AI agent for their vote and statement
Returns:
Dict with 'vote' (yes/no/abstain) and 'statement' (brief explanation)
"""
system_prompt = self.load_agent_prompt(country['prompt_path'])
user_prompt = f"""You are voting on the following UN General Assembly resolution:
{motion['text']}
You must respond with a JSON object containing:
1. "vote": Your vote - must be exactly one of: "yes", "no", or "abstain"
2. "statement": A brief statement (2-4 sentences) explaining your country's position
IMPORTANT: Your statement must articulate {country['name']}'s UNIQUE perspective, national interests, and specific reasons for this vote. Reference your country's:
- Historical positions on this issue
- Regional concerns and alliances
- Domestic political considerations
- Specific clauses in the resolution that align with or contradict your interests
Avoid generic diplomatic language. Be specific to {country['name']}'s situation and worldview.
Your response must be valid JSON in this exact format:
{{
"vote": "yes",
"statement": "Your explanation here."
}}"""
try:
if self.provider == "cloud":
if self.use_anthropic:
# Anthropic API
response = self.client.messages.create(
model=self.model,
max_tokens=800,
system=system_prompt,
messages=[
{"role": "user", "content": user_prompt}
],
temperature=0.7
)
content = response.content[0].text
else:
# OpenAI API
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=0.7,
max_tokens=800
)
content = response.choices[0].message.content
else:
response = self.client.chat(
model=self.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
)
content = response['message']['content']
# Extract JSON from response (handle markdown code blocks)
content = content.strip()
if content.startswith("```"):
# Remove markdown code block
content = re.sub(r'^```(?:json)?\n', '', content)
content = re.sub(r'\n```$', '', content)
# Parse JSON response
result = json.loads(content)
# Validate response
if "vote" not in result or "statement" not in result:
raise ValueError("Response missing required fields")
if result["vote"].lower() not in self.VALID_VOTES:
raise ValueError(f"Invalid vote: {result['vote']}")
result["vote"] = result["vote"].lower()
return result
except json.JSONDecodeError as e:
print(f" ⚠ JSON parse error for {country['name']}: {e}")
print(f" Raw response: {content[:200]}...")
return {
"vote": "abstain",
"statement": f"[Error: Unable to parse response]",
"error": str(e)
}
except Exception as e:
print(f" ⚠ Error querying {country['name']}: {e}")
return {
"vote": "abstain",
"statement": f"[Error: {str(e)}]",
"error": str(e)
}
def run_motion(self, motion_id: str, sample_size: Optional[int] = None) -> Dict:
"""
Run a motion through all country agents
Args:
motion_id: ID of the motion to run
sample_size: If set, only query this many countries (for testing)
Returns:
Dict containing all votes and metadata
"""
print(f"\n{'='*60}")
print(f"Running Motion: {motion_id}")
print(f"Provider: {self.provider} | Model: {self.model}")
print(f"{'='*60}\n")
# Load motion
motion = self.load_motion(motion_id)
print(f"βœ“ Loaded motion from {motion['path']}\n")
# Get countries
countries = self.get_country_list()
if sample_size:
countries = countries[:sample_size]
print(f"πŸ“Š Querying {sample_size} countries (sample mode)\n")
else:
print(f"πŸ“Š Querying {len(countries)} countries\n")
# Query each country
votes = []
vote_counts = {"yes": 0, "no": 0, "abstain": 0}
for i, country in enumerate(countries, 1):
print(f"[{i}/{len(countries)}] Querying {country['name']}...", end=" ", flush=True)
result = self.query_agent(country, motion)
vote_counts[result["vote"]] += 1
votes.append({
"country": country['name'],
"country_slug": country['slug'],
"vote": result["vote"],
"statement": result["statement"],
"error": result.get("error")
})
# Print vote result
vote_emoji = {"yes": "βœ…", "no": "❌", "abstain": "βšͺ"}
print(f"{vote_emoji[result['vote']]} {result['vote'].upper()}")
# Compile results
results = {
"motion_id": motion_id,
"motion_path": motion['path'],
"timestamp": datetime.utcnow().isoformat() + "Z",
"provider": self.provider,
"model": self.model,
"total_votes": len(countries),
"vote_summary": vote_counts,
"votes": votes
}
# Print summary
print(f"\n{'='*60}")
print(f"Vote Summary:")
print(f" YES: {vote_counts['yes']:3d} ({vote_counts['yes']/len(countries)*100:.1f}%)")
print(f" NO: {vote_counts['no']:3d} ({vote_counts['no']/len(countries)*100:.1f}%)")
print(f" ABSTAIN: {vote_counts['abstain']:3d} ({vote_counts['abstain']/len(countries)*100:.1f}%)")
print(f"{'='*60}\n")
return results
def save_results(self, results: Dict):
"""Save simulation results to file"""
# Create results directory if it doesn't exist
self.results_dir.mkdir(parents=True, exist_ok=True)
# Generate filename with timestamp
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
filename = f"{results['motion_id']}_{timestamp}.json"
filepath = self.results_dir / filename
# Save results
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"βœ“ Results saved to: {filepath}")
# Also create/update a "latest" symlink or copy
latest_filepath = self.results_dir / f"{results['motion_id']}_latest.json"
with open(latest_filepath, 'w', encoding='utf-8') as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"βœ“ Latest results: {latest_filepath}")
def main():
parser = argparse.ArgumentParser(
description="Run UN motion simulation with AI agents",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run with cloud API (default)
python scripts/run_motion.py 01_gaza_ceasefire_resolution
# Run with local model
python scripts/run_motion.py 01_gaza_ceasefire_resolution --provider local
# Test with only 5 countries
python scripts/run_motion.py 01_gaza_ceasefire_resolution --sample 5
# Use specific model
python scripts/run_motion.py 01_gaza_ceasefire_resolution --model gpt-4-turbo
"""
)
parser.add_argument(
"motion_id",
help="ID of the motion to run (e.g., 01_gaza_ceasefire_resolution)"
)
parser.add_argument(
"--provider",
choices=["cloud", "local"],
default="cloud",
help="AI provider: cloud (API) or local (Ollama)"
)
parser.add_argument(
"--model",
help="Model name (optional, uses config defaults)"
)
parser.add_argument(
"--sample",
type=int,
help="Only query N countries (for testing)"
)
args = parser.parse_args()
# Run simulation
try:
runner = MotionRunner(provider=args.provider, model=args.model)
results = runner.run_motion(args.motion_id, sample_size=args.sample)
runner.save_results(results)
print("\nβœ“ Motion simulation complete!")
except FileNotFoundError as e:
print(f"\n❌ Error: {e}", file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\n\n⚠ Simulation interrupted by user")
sys.exit(130)
except Exception as e:
print(f"\n❌ Unexpected error: {e}", file=sys.stderr)
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()