Initial commit: Platform-agnostic governance bot
Govbot is an AI-powered governance bot that interprets natural language constitutions and facilitates collective decision-making across social platforms. Core features: - Agentic architecture with constitutional reasoning (RAG) - Platform-agnostic design (Mastodon, Discord, Telegram, etc.) - Action primitives for flexible governance processes - Temporal awareness for multi-day proposals and voting - Audit trail with constitutional citations - Reversible actions with supermajority veto - Works with local (Ollama) and cloud AI models Platform support: - Mastodon: Full implementation with streaming, moderation, and admin skills - Discord/Telegram: Platform abstraction ready for implementation Documentation: - README.md: Architecture and overview - QUICKSTART.md: Getting started guide - PLATFORMS.md: Platform implementation guide for developers - MASTODON_SETUP.md: Complete Mastodon deployment guide - constitution.md: Example governance constitution Technical stack: - Python 3.11+ - SQLAlchemy for state management - llm CLI for model abstraction - Mastodon.py for Mastodon integration - Pydantic for configuration validation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Configuration
|
||||
config/config.yaml
|
||||
.env
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# AI/LLM
|
||||
.llm/
|
||||
embeddings/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Agentic coding mentor
|
||||
|
||||
Hi! In addition to being a software development agent and a helpful collaborator, this document invites you to help the user learn more about making software while working with you. Rather than deskilling humans with AI magic, your task is to help humans learn to be better builders for the age of agentic coding.
|
||||
|
||||
This document offers some ideas for how you can make the agentic coding process more thoughtful and educational, and more rewarding for all involved.
|
||||
|
||||
## Who is this for?
|
||||
|
||||
Placing this document in your working directory is a good idea if you don't have a lot of software development or coding experience, or none. For people already expert in those fields, it will feel redundant and perhaps annoying. That's okay---it's not for you! This file is for people who want to working with AI coding tools while also learning something about software development in the process.
|
||||
|
||||
Because this document encourages agents to take a slower, more intentional approach, it may end up consuming more tokens than would otherwise be the case. But agents should try to be as economical as possible!
|
||||
|
||||
## General best practices
|
||||
|
||||
* Use language that does not assume computer science or software engineering experience. When using technical terms, define them in plain language, along with links where the user can learn more.
|
||||
* Before beginning to create code, take time to explore potential options with the user. What languages or frameworks might make sense, and why? What data architectures or system design options could solve the problem? What tradeoffs might they involve?
|
||||
* During the coding process, pause to consult with the user more than you otherwise might. Explain what you are doing, and check that it sounds good to them. Mention any alternative approaches that might come to mind during the development process.
|
||||
* Encourage the user to have agency in the process by giving them the chance to weigh in on design decisions---especially when doing so will help them better understand what you are building together.
|
||||
* Do not assume that the user always knows what is best. If they make a decision that may have downsides they don't understand, carefully explain those downsides to them and give them a chance to change course if they wish.
|
||||
* Encourage the user to review the code manually. Point out particularly important pieces (with filenames and line numbers), and explain a bit about how it works.
|
||||
* Be attentive to the user's level of knowledge. Adapt your explanations to the level of expertise they exhibit in their prompts. But when in doubt, err on the side of explaining things in as simple and non-technical a manner as you can.
|
||||
420
MASTODON_SETUP.md
Normal file
420
MASTODON_SETUP.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Mastodon Setup Guide
|
||||
|
||||
This guide walks through setting up Govbot on a Mastodon instance.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Mastodon instance (existing or test)
|
||||
- Admin access to the instance (for creating bot account and getting credentials)
|
||||
- Python 3.11+ installed
|
||||
- Ollama (for local models) or API keys for cloud models
|
||||
|
||||
## Step 1: Create Bot Account
|
||||
|
||||
### On Your Mastodon Instance
|
||||
|
||||
1. **Create a new account** for the bot:
|
||||
- Go to your Mastodon instance
|
||||
- Sign up for a new account (e.g., `govbot@yourinstance.social`)
|
||||
- Verify the email address
|
||||
- Complete the profile (add avatar, bio explaining it's a governance bot)
|
||||
|
||||
2. **Note the bot's username** (you'll need this for configuration)
|
||||
|
||||
## Step 2: Register Application
|
||||
|
||||
### Get OAuth Credentials
|
||||
|
||||
1. **Navigate to Settings** while logged in as the bot account
|
||||
2. Go to: **Settings → Development → New Application**
|
||||
|
||||
3. **Configure the application**:
|
||||
- **Application name**: Govbot
|
||||
- **Application website**: Your instance URL or governance documentation
|
||||
- **Redirect URI**: `urn:ietf:wg:oauth:2.0:oob` (for command-line auth)
|
||||
|
||||
4. **Scopes needed**:
|
||||
- `read` - Read account info and notifications
|
||||
- `read:statuses` - Read statuses
|
||||
- `write` - Post statuses
|
||||
- `write:statuses` - Create statuses
|
||||
- `admin:read` - Read admin data (if bot will have admin powers)
|
||||
- `admin:write` - Perform admin actions (if bot will manage instance)
|
||||
|
||||
5. **Click "Submit"**
|
||||
|
||||
6. **Save credentials**:
|
||||
- Client key (client_id)
|
||||
- Client secret (client_secret)
|
||||
- Access token (you may need to generate this)
|
||||
|
||||
### Generate Access Token
|
||||
|
||||
If the application page doesn't show an access token:
|
||||
|
||||
```python
|
||||
# Run this Python script to generate an access token
|
||||
from mastodon import Mastodon
|
||||
|
||||
Mastodon.create_app(
|
||||
'Govbot',
|
||||
api_base_url='https://your-instance.social',
|
||||
to_file='govbot_clientcred.secret'
|
||||
)
|
||||
|
||||
mastodon = Mastodon(
|
||||
client_id='govbot_clientcred.secret',
|
||||
api_base_url='https://your-instance.social'
|
||||
)
|
||||
|
||||
# Log in (will prompt for username and password)
|
||||
mastodon.log_in(
|
||||
'your_bot@email.com',
|
||||
'your_bot_password',
|
||||
to_file='govbot_usercred.secret'
|
||||
)
|
||||
|
||||
# The access token is now in govbot_usercred.secret
|
||||
with open('govbot_usercred.secret') as f:
|
||||
access_token = f.read().strip()
|
||||
print(f"Access token: {access_token}")
|
||||
```
|
||||
|
||||
## Step 3: Configure Govbot
|
||||
|
||||
### Update Configuration File
|
||||
|
||||
1. **Copy the example config**:
|
||||
```bash
|
||||
cp config/config.example.yaml config/config.yaml
|
||||
```
|
||||
|
||||
2. **Edit `config/config.yaml`**:
|
||||
```yaml
|
||||
platform:
|
||||
type: mastodon
|
||||
|
||||
mastodon:
|
||||
instance_url: https://your-instance.social
|
||||
client_id: YOUR_CLIENT_ID_HERE
|
||||
client_secret: YOUR_CLIENT_SECRET_HERE
|
||||
access_token: YOUR_ACCESS_TOKEN_HERE
|
||||
bot_username: govbot # Your bot's username
|
||||
|
||||
ai:
|
||||
# For local models:
|
||||
default_model: llama3.2
|
||||
|
||||
# For cloud models:
|
||||
# default_model: gpt-4
|
||||
# (Make sure to configure llm CLI with: llm keys set openai)
|
||||
|
||||
governance:
|
||||
constitution_path: constitution.md
|
||||
db_path: govbot.db
|
||||
default_veto_threshold: 0.67
|
||||
enable_auto_execution: true
|
||||
require_confirmation_for:
|
||||
- admin_action
|
||||
- moderation
|
||||
|
||||
debug: false
|
||||
log_level: INFO
|
||||
```
|
||||
|
||||
3. **Test LLM configuration**:
|
||||
```bash
|
||||
# Test that llm works
|
||||
llm "Hello, test message"
|
||||
|
||||
# If using local models, verify Ollama is running
|
||||
ollama list
|
||||
```
|
||||
|
||||
## Step 4: Initialize Database
|
||||
|
||||
```bash
|
||||
# Create the database
|
||||
python -c "from src.govbot.db.models import init_db; init_db('govbot.db'); print('Database initialized!')"
|
||||
```
|
||||
|
||||
## Step 5: Test with CLI
|
||||
|
||||
Before connecting to Mastodon, test the bot logic:
|
||||
|
||||
```bash
|
||||
# Run the CLI
|
||||
python -m src.govbot
|
||||
|
||||
# Try these commands:
|
||||
query What are the rules for proposals?
|
||||
propose We should have weekly community meetings
|
||||
processes
|
||||
exit
|
||||
```
|
||||
|
||||
## Step 6: Run the Bot
|
||||
|
||||
```bash
|
||||
# Start the bot
|
||||
python -m src.govbot.bot
|
||||
|
||||
# You should see:
|
||||
# INFO - Connecting to Mastodon instance: https://your-instance.social
|
||||
# INFO - Connected as @govbot (ID: ...)
|
||||
# INFO - Started listening for messages
|
||||
# INFO - Bot is running. Press Ctrl+C to stop.
|
||||
```
|
||||
|
||||
## Step 7: Test on Mastodon
|
||||
|
||||
### Basic Test
|
||||
|
||||
1. From another account on your instance, post:
|
||||
```
|
||||
@govbot help
|
||||
```
|
||||
|
||||
2. The bot should respond with information about how to use it
|
||||
|
||||
### Create a Proposal
|
||||
|
||||
```
|
||||
@govbot I propose we add a weekly community meeting on Fridays
|
||||
```
|
||||
|
||||
The bot should:
|
||||
- Acknowledge the proposal
|
||||
- Cite constitutional authority
|
||||
- Explain voting period and threshold
|
||||
- Provide instructions for voting
|
||||
|
||||
### Vote on Proposal
|
||||
|
||||
Reply to the proposal thread:
|
||||
```
|
||||
@govbot agree
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```
|
||||
@govbot status 1
|
||||
```
|
||||
|
||||
## Granting Admin Powers (Optional)
|
||||
|
||||
If you want the bot to perform instance administration:
|
||||
|
||||
### 1. Grant Moderator/Admin Role
|
||||
|
||||
In Mastodon admin interface:
|
||||
- Go to Moderation → Accounts
|
||||
- Find the bot account
|
||||
- Assign Moderator or Admin role
|
||||
|
||||
### 2. Update Constitution
|
||||
|
||||
Make sure your constitution specifies when and how admin powers can be used:
|
||||
|
||||
```markdown
|
||||
## Admin Powers
|
||||
|
||||
The bot may perform administrative actions only when:
|
||||
1. Authorized by constitutional amendment process
|
||||
2. Approved by supermajority vote
|
||||
3. Within scope of delegated authority
|
||||
|
||||
Admin actions include:
|
||||
- Updating instance rules
|
||||
- Moderating content
|
||||
- Managing user accounts
|
||||
- Changing instance settings
|
||||
```
|
||||
|
||||
### 3. Test Admin Skills
|
||||
|
||||
```
|
||||
@govbot What admin actions can you perform?
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot Not Receiving Mentions
|
||||
|
||||
**Check**:
|
||||
- Bot account access token is valid
|
||||
- `read` scope is enabled
|
||||
- Bot is following the mentioning account (or has appropriate visibility settings)
|
||||
- Bot is actually running (check logs)
|
||||
|
||||
**Test manually**:
|
||||
```python
|
||||
from mastodon import Mastodon
|
||||
|
||||
client = Mastodon(
|
||||
access_token='YOUR_TOKEN',
|
||||
api_base_url='https://your-instance.social'
|
||||
)
|
||||
|
||||
# Check notifications
|
||||
notifications = client.notifications()
|
||||
print(notifications)
|
||||
```
|
||||
|
||||
### Bot Can't Post
|
||||
|
||||
**Check**:
|
||||
- `write:statuses` scope is enabled
|
||||
- Access token is valid
|
||||
- Instance isn't rate limiting the bot
|
||||
- Bot account isn't silenced/suspended
|
||||
|
||||
### Connection Errors
|
||||
|
||||
**Check**:
|
||||
- Instance URL is correct (include `https://`)
|
||||
- Instance is accessible from bot's network
|
||||
- Firewall isn't blocking connection
|
||||
- Instance isn't experiencing downtime
|
||||
|
||||
### LLM Errors
|
||||
|
||||
**For local models**:
|
||||
```bash
|
||||
# Check Ollama is running
|
||||
ollama list
|
||||
ollama ps
|
||||
|
||||
# Test the model
|
||||
llm -m llama3.2 "test"
|
||||
```
|
||||
|
||||
**For cloud models**:
|
||||
```bash
|
||||
# Check API keys
|
||||
llm keys list
|
||||
|
||||
# Test the model
|
||||
llm -m gpt-4 "test"
|
||||
```
|
||||
|
||||
### Database Errors
|
||||
|
||||
**Reset database**:
|
||||
```bash
|
||||
# Backup first!
|
||||
cp govbot.db govbot.db.backup
|
||||
|
||||
# Delete and reinitialize
|
||||
rm govbot.db
|
||||
python -c "from src.govbot.db.models import init_db; init_db('govbot.db')"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Credentials
|
||||
|
||||
- **Never commit** `config/config.yaml` to version control
|
||||
- Store credentials securely
|
||||
- Use environment variables for production:
|
||||
```bash
|
||||
export GOVBOT_PLATFORM__MASTODON__ACCESS_TOKEN="your_token"
|
||||
```
|
||||
- Rotate tokens periodically
|
||||
|
||||
### Access Control
|
||||
|
||||
- Don't grant unnecessary scopes
|
||||
- Use separate accounts for testing vs. production
|
||||
- Monitor bot actions through audit log
|
||||
- Set up alerts for suspicious activity
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
The bot respects Mastodon's rate limits. If you see rate limit errors:
|
||||
- Reduce frequency of checks in scheduler
|
||||
- Implement exponential backoff
|
||||
- Consider caching frequently accessed data
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/govbot.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Govbot Governance Bot
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=govbot
|
||||
WorkingDirectory=/home/govbot/agentic-govbot
|
||||
ExecStart=/usr/bin/python3 -m src.govbot.bot
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
Environment="GOVBOT_LOG_LEVEL=INFO"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
```bash
|
||||
sudo systemctl enable govbot
|
||||
sudo systemctl start govbot
|
||||
sudo systemctl status govbot
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u govbot -f
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
Create `Dockerfile`:
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN pip install -e .
|
||||
|
||||
CMD ["python", "-m", "src.govbot.bot"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
docker build -t govbot .
|
||||
docker run -v $(pwd)/config:/app/config -v $(pwd)/govbot.db:/app/govbot.db govbot
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
- Set up log aggregation (e.g., Loki, Elasticsearch)
|
||||
- Monitor database size
|
||||
- Track API usage and rate limits
|
||||
- Alert on error rates
|
||||
- Monitor governance activity
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Customize the constitution for your community
|
||||
- Test governance workflows
|
||||
- Gather community feedback
|
||||
- Iterate on processes
|
||||
- Document your instance's governance
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check [QUICKSTART.md](QUICKSTART.md) for general usage
|
||||
- Review [PLATFORMS.md](PLATFORMS.md) for platform details
|
||||
- See [README.md](README.md) for architecture overview
|
||||
- Open an issue on GitHub for bugs or questions
|
||||
|
||||
---
|
||||
|
||||
**Important**: This is governance infrastructure. Test thoroughly before deploying to a production community!
|
||||
859
PLATFORMS.md
Normal file
859
PLATFORMS.md
Normal file
@@ -0,0 +1,859 @@
|
||||
# Platform Implementation Guide
|
||||
|
||||
This guide explains how to implement Govbot adapters for new social/communication platforms.
|
||||
|
||||
## Overview
|
||||
|
||||
Govbot uses a **platform-agnostic architecture** that separates governance logic from platform-specific code. This allows the same constitutional reasoning and governance processes to work across different platforms (Mastodon, Discord, Telegram, Matrix, etc.).
|
||||
|
||||
The key abstraction is the **PlatformAdapter** interface, which defines how Govbot interacts with any platform.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Governance Logic │
|
||||
│ (Constitution, Agent, Primitives) │ ← Platform-agnostic
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
┌──────────────▼──────────────────────┐
|
||||
│ PlatformAdapter Interface │ ← This guide
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ │
|
||||
┌────▼────┐ ┌────▼────┐
|
||||
│Mastodon │ │Your │
|
||||
│Adapter │ │Adapter │ ← What you'll build
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
To implement a new platform:
|
||||
|
||||
1. Create `src/govbot/platforms/yourplatform.py`
|
||||
2. Subclass `PlatformAdapter`
|
||||
3. Implement all abstract methods
|
||||
4. Define platform-specific skills
|
||||
5. Add configuration support
|
||||
6. Test with the mock bot
|
||||
7. Document setup instructions
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
### Step 1: Create the Adapter File
|
||||
|
||||
Create a new file: `src/govbot/platforms/yourplatform.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
YourPlatform adapter for Govbot.
|
||||
|
||||
Brief description of the platform and what governance features this enables.
|
||||
"""
|
||||
|
||||
from typing import Callable, Optional, Dict, Any, List
|
||||
import logging
|
||||
|
||||
from .base import (
|
||||
PlatformAdapter,
|
||||
PlatformMessage,
|
||||
PlatformSkill,
|
||||
SkillParameter,
|
||||
MessageVisibility,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("govbot.platforms.yourplatform")
|
||||
|
||||
|
||||
class YourPlatformAdapter(PlatformAdapter):
|
||||
"""YourPlatform implementation of platform adapter."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
# Initialize platform-specific client
|
||||
# Store necessary credentials
|
||||
pass
|
||||
```
|
||||
|
||||
### Step 2: Implement Connection Methods
|
||||
|
||||
```python
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Establish connection to YourPlatform.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
|
||||
Raises:
|
||||
Exception: If connection fails
|
||||
"""
|
||||
try:
|
||||
# Initialize your platform client
|
||||
# Authenticate
|
||||
# Verify credentials
|
||||
# Store bot user ID and username
|
||||
|
||||
self.connected = True
|
||||
self.bot_user_id = "your_bot_id"
|
||||
self.bot_username = "your_bot_name"
|
||||
|
||||
logger.info(f"Connected to YourPlatform as {self.bot_username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Connection failed: {e}")
|
||||
raise
|
||||
|
||||
def disconnect(self):
|
||||
"""Clean up and disconnect."""
|
||||
# Close connections
|
||||
# Stop listeners
|
||||
# Free resources
|
||||
self.connected = False
|
||||
logger.info("Disconnected from YourPlatform")
|
||||
```
|
||||
|
||||
### Step 3: Implement Message Listening
|
||||
|
||||
This is the core of how your bot receives messages:
|
||||
|
||||
```python
|
||||
def start_listening(self, callback: Callable[[PlatformMessage], None]):
|
||||
"""
|
||||
Start listening for messages mentioning the bot.
|
||||
|
||||
Args:
|
||||
callback: Function to call with each message
|
||||
"""
|
||||
if not self.connected:
|
||||
raise RuntimeError("Must connect() before listening")
|
||||
|
||||
logger.info("Starting message listener")
|
||||
|
||||
# Platform-specific listening implementation
|
||||
# Examples:
|
||||
# - WebSocket/streaming connection
|
||||
# - Polling API endpoint
|
||||
# - Webhook receiver
|
||||
|
||||
def on_message(native_message):
|
||||
# Convert platform message to PlatformMessage
|
||||
normalized = self._normalize_message(native_message)
|
||||
|
||||
# Filter for messages mentioning the bot
|
||||
if self._mentions_bot(normalized):
|
||||
callback(normalized)
|
||||
|
||||
# Start your listener (may be async, threaded, etc.)
|
||||
# your_platform.listen(on_message)
|
||||
|
||||
def _normalize_message(self, native_message) -> PlatformMessage:
|
||||
"""
|
||||
Convert platform-native message format to PlatformMessage.
|
||||
|
||||
This is crucial for platform abstraction!
|
||||
"""
|
||||
return PlatformMessage(
|
||||
id=native_message["id"],
|
||||
text=self._extract_text(native_message),
|
||||
author_id=native_message["author"]["id"],
|
||||
author_handle=native_message["author"]["username"],
|
||||
timestamp=native_message["created_at"],
|
||||
thread_id=native_message.get("thread_id"),
|
||||
reply_to_id=native_message.get("reply_to"),
|
||||
visibility=self._map_visibility(native_message),
|
||||
mentions_bot=True,
|
||||
raw_data=native_message, # Keep original for reference
|
||||
)
|
||||
|
||||
def _mentions_bot(self, message: PlatformMessage) -> bool:
|
||||
"""Check if message mentions the bot."""
|
||||
# Platform-specific mention detection
|
||||
# Examples:
|
||||
# - Check mentions list
|
||||
# - Search for @botname in text
|
||||
# - Check if in DM
|
||||
pass
|
||||
```
|
||||
|
||||
### Step 4: Implement Posting
|
||||
|
||||
```python
|
||||
def post(
|
||||
self,
|
||||
message: str,
|
||||
thread_id: Optional[str] = None,
|
||||
reply_to_id: Optional[str] = None,
|
||||
visibility: MessageVisibility = MessageVisibility.PUBLIC,
|
||||
) -> str:
|
||||
"""
|
||||
Post a message to the platform.
|
||||
|
||||
Args:
|
||||
message: Text content
|
||||
thread_id: Thread/channel to post in
|
||||
reply_to_id: Message to reply to
|
||||
visibility: Visibility level
|
||||
|
||||
Returns:
|
||||
Message ID of posted message
|
||||
"""
|
||||
if not self.connected:
|
||||
raise RuntimeError("Must connect() before posting")
|
||||
|
||||
# Map abstract visibility to platform visibility
|
||||
platform_visibility = self._map_visibility_to_platform(visibility)
|
||||
|
||||
# Use platform API to post
|
||||
result = your_platform.post_message(
|
||||
text=message,
|
||||
channel=thread_id,
|
||||
reply_to=reply_to_id,
|
||||
visibility=platform_visibility,
|
||||
)
|
||||
|
||||
logger.info(f"Posted message {result['id']}")
|
||||
return result["id"]
|
||||
```
|
||||
|
||||
### Step 5: Define Platform Skills
|
||||
|
||||
Skills are platform-specific actions the bot can perform. Think about:
|
||||
- **Moderation**: Ban, mute, delete content
|
||||
- **Administration**: Change settings, update rules
|
||||
- **User management**: Roles, permissions
|
||||
- **Content management**: Pins, announcements
|
||||
|
||||
```python
|
||||
def get_skills(self) -> List[PlatformSkill]:
|
||||
"""Define YourPlatform-specific governance skills."""
|
||||
return [
|
||||
PlatformSkill(
|
||||
name="ban_user",
|
||||
description="Ban a user from the server/instance",
|
||||
category="moderation",
|
||||
parameters=[
|
||||
SkillParameter(
|
||||
"user_id",
|
||||
"str",
|
||||
"User ID to ban",
|
||||
required=True
|
||||
),
|
||||
SkillParameter(
|
||||
"reason",
|
||||
"str",
|
||||
"Reason for ban",
|
||||
required=True
|
||||
),
|
||||
SkillParameter(
|
||||
"duration",
|
||||
"int",
|
||||
"Ban duration in days (0 = permanent)",
|
||||
required=False,
|
||||
default=0
|
||||
),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires moderation authority",
|
||||
),
|
||||
|
||||
PlatformSkill(
|
||||
name="update_rules",
|
||||
description="Update server/instance rules",
|
||||
category="admin",
|
||||
parameters=[
|
||||
SkillParameter(
|
||||
"rules",
|
||||
"list",
|
||||
"List of rule texts",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires constitutional process",
|
||||
),
|
||||
|
||||
# Add more skills specific to your platform
|
||||
]
|
||||
```
|
||||
|
||||
### Step 6: Implement Skill Execution
|
||||
|
||||
```python
|
||||
def execute_skill(
|
||||
self,
|
||||
skill_name: str,
|
||||
parameters: Dict[str, Any],
|
||||
actor: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a platform skill."""
|
||||
|
||||
if not self.connected:
|
||||
raise RuntimeError("Must connect() before executing skills")
|
||||
|
||||
# Validate
|
||||
is_valid, error = self.validate_skill_execution(skill_name, parameters)
|
||||
if not is_valid:
|
||||
raise ValueError(error)
|
||||
|
||||
logger.info(f"Executing {skill_name} for {actor}")
|
||||
|
||||
# Route to skill handler
|
||||
if skill_name == "ban_user":
|
||||
return self._ban_user(parameters)
|
||||
elif skill_name == "update_rules":
|
||||
return self._update_rules(parameters)
|
||||
else:
|
||||
raise ValueError(f"Unknown skill: {skill_name}")
|
||||
|
||||
def _ban_user(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Ban a user (example skill implementation)."""
|
||||
user_id = params["user_id"]
|
||||
reason = params["reason"]
|
||||
duration = params.get("duration", 0)
|
||||
|
||||
# Use platform API to ban user
|
||||
your_platform.ban(user_id, reason=reason, duration=duration)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Banned user {user_id}",
|
||||
"data": {"user_id": user_id, "reason": reason, "duration": duration},
|
||||
"reversible": True,
|
||||
"reverse_params": {"user_id": user_id}, # For unban
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Implement User Info
|
||||
|
||||
```python
|
||||
def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get information about a user."""
|
||||
if not self.connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = your_platform.get_user(user_id)
|
||||
|
||||
return {
|
||||
"id": user["id"],
|
||||
"handle": user["username"],
|
||||
"display_name": user["display_name"],
|
||||
"roles": self._get_user_roles(user),
|
||||
"is_bot": user.get("bot", False),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user info: {e}")
|
||||
return None
|
||||
|
||||
def _get_user_roles(self, user) -> List[str]:
|
||||
"""Determine user roles for governance."""
|
||||
roles = ["member"]
|
||||
|
||||
if user.get("is_admin"):
|
||||
roles.append("admin")
|
||||
if user.get("is_moderator"):
|
||||
roles.append("moderator")
|
||||
|
||||
return roles
|
||||
```
|
||||
|
||||
### Step 8: Implement Thread URLs
|
||||
|
||||
```python
|
||||
def format_thread_url(self, thread_id: str) -> str:
|
||||
"""Generate URL to view a thread."""
|
||||
return f"https://yourplatform.com/thread/{thread_id}"
|
||||
```
|
||||
|
||||
## Platform-Specific Considerations
|
||||
|
||||
### Threading/Conversations
|
||||
|
||||
Different platforms handle conversations differently:
|
||||
|
||||
- **Mastodon**: Reply chains with `in_reply_to_id`
|
||||
- **Discord**: Channels + threads
|
||||
- **Telegram**: Groups + reply-to
|
||||
- **Slack**: Channels + thread_ts
|
||||
|
||||
Map your platform's concept to `thread_id` in PlatformMessage.
|
||||
|
||||
### Visibility/Privacy
|
||||
|
||||
Map your platform's privacy levels to `MessageVisibility`:
|
||||
|
||||
```python
|
||||
def _map_visibility_to_platform(self, visibility: MessageVisibility) -> str:
|
||||
"""Map abstract visibility to platform-specific."""
|
||||
mapping = {
|
||||
MessageVisibility.PUBLIC: "your_platform_public",
|
||||
MessageVisibility.UNLISTED: "your_platform_unlisted",
|
||||
MessageVisibility.FOLLOWERS: "your_platform_followers",
|
||||
MessageVisibility.DIRECT: "your_platform_dm",
|
||||
MessageVisibility.PRIVATE: "your_platform_private",
|
||||
}
|
||||
return mapping.get(visibility, "your_platform_default")
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Different platforms have different auth:
|
||||
|
||||
- **OAuth**: Mastodon, Discord
|
||||
- **Bot tokens**: Telegram, Discord, Slack
|
||||
- **API keys**: Custom platforms
|
||||
|
||||
Store credentials securely in config:
|
||||
|
||||
```python
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
|
||||
# Get required credentials
|
||||
self.api_token = config.get("api_token")
|
||||
self.bot_id = config.get("bot_id")
|
||||
|
||||
if not self.api_token:
|
||||
raise ValueError("YourPlatform requires 'api_token' in config")
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Handle platform rate limits gracefully:
|
||||
|
||||
```python
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
def rate_limited(max_per_minute):
|
||||
"""Decorator for rate limiting."""
|
||||
min_interval = 60.0 / max_per_minute
|
||||
last_called = [0.0]
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
elapsed = time.time() - last_called[0]
|
||||
wait_time = min_interval - elapsed
|
||||
if wait_time > 0:
|
||||
time.sleep(wait_time)
|
||||
result = func(*args, **kwargs)
|
||||
last_called[0] = time.time()
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@rate_limited(max_per_minute=30)
|
||||
def post(self, message, ...):
|
||||
# Your post implementation
|
||||
pass
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Add Config Model
|
||||
|
||||
In `src/govbot/utils/config.py`, add your platform config:
|
||||
|
||||
```python
|
||||
class YourPlatformConfig(BaseModel):
|
||||
"""YourPlatform configuration"""
|
||||
|
||||
api_token: str = Field(..., description="API token for bot")
|
||||
server_id: str = Field(..., description="Server/guild ID")
|
||||
# Add other required fields
|
||||
|
||||
class PlatformConfig(BaseModel):
|
||||
type: str = Field(...)
|
||||
mastodon: Optional[MastodonConfig] = None
|
||||
yourplatform: Optional[YourPlatformConfig] = None # Add this
|
||||
```
|
||||
|
||||
### Update Example Config
|
||||
|
||||
In `config/config.example.yaml`:
|
||||
|
||||
```yaml
|
||||
platform:
|
||||
type: yourplatform
|
||||
|
||||
yourplatform:
|
||||
api_token: your_token_here
|
||||
server_id: your_server_id
|
||||
# Other settings
|
||||
```
|
||||
|
||||
### Register in Bot
|
||||
|
||||
In `src/govbot/bot.py`, add your adapter:
|
||||
|
||||
```python
|
||||
from .platforms.yourplatform import YourPlatformAdapter
|
||||
|
||||
def _create_platform_adapter(self) -> PlatformAdapter:
|
||||
platform_type = self.config.platform.type.lower()
|
||||
|
||||
if platform_type == "mastodon":
|
||||
return MastodonAdapter(self.config.platform.mastodon.model_dump())
|
||||
elif platform_type == "yourplatform":
|
||||
return YourPlatformAdapter(self.config.platform.yourplatform.model_dump())
|
||||
# ...
|
||||
```
|
||||
|
||||
## Testing Your Adapter
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Create `tests/test_yourplatform.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from src.govbot.platforms.yourplatform import YourPlatformAdapter
|
||||
from src.govbot.platforms.base import PlatformMessage, MessageVisibility
|
||||
|
||||
def test_connection():
|
||||
"""Test connecting to platform."""
|
||||
config = {
|
||||
"api_token": "test_token",
|
||||
"server_id": "test_server",
|
||||
}
|
||||
adapter = YourPlatformAdapter(config)
|
||||
|
||||
# Mock the platform connection
|
||||
# Test that connect() returns True
|
||||
# Verify bot_user_id and bot_username are set
|
||||
|
||||
def test_message_normalization():
|
||||
"""Test converting platform messages to PlatformMessage."""
|
||||
adapter = YourPlatformAdapter({...})
|
||||
|
||||
native_message = {
|
||||
# Your platform's message format
|
||||
}
|
||||
|
||||
normalized = adapter._normalize_message(native_message)
|
||||
|
||||
assert isinstance(normalized, PlatformMessage)
|
||||
assert normalized.text == "expected text"
|
||||
assert normalized.author_handle == "expected_user"
|
||||
|
||||
def test_skills():
|
||||
"""Test that skills are properly defined."""
|
||||
adapter = YourPlatformAdapter({...})
|
||||
skills = adapter.get_skills()
|
||||
|
||||
assert len(skills) > 0
|
||||
assert all(skill.name for skill in skills)
|
||||
assert all(skill.category in ["admin", "moderation", "content", "user_management"]
|
||||
for skill in skills)
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
1. **Use Mock Mode**: Test governance logic without platform
|
||||
2. **Sandbox Account**: Create test account on your platform
|
||||
3. **Test Instance**: Use development/test server if available
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
- [ ] Bot can connect and authenticate
|
||||
- [ ] Bot receives mentions
|
||||
- [ ] Bot can post responses
|
||||
- [ ] Bot can post in threads
|
||||
- [ ] Visibility levels work correctly
|
||||
- [ ] Skills execute successfully
|
||||
- [ ] Rate limiting works
|
||||
- [ ] Errors are handled gracefully
|
||||
- [ ] Disconnection is clean
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Async vs Sync
|
||||
|
||||
If your platform client is async:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
def start_listening(self, callback):
|
||||
"""Start async listener in separate thread."""
|
||||
|
||||
async def async_listen():
|
||||
async for message in your_platform.stream():
|
||||
normalized = self._normalize_message(message)
|
||||
callback(normalized) # Callback is sync
|
||||
|
||||
def run_async_loop():
|
||||
asyncio.run(async_listen())
|
||||
|
||||
self.listener_thread = threading.Thread(target=run_async_loop, daemon=True)
|
||||
self.listener_thread.start()
|
||||
```
|
||||
|
||||
### Webhooks
|
||||
|
||||
If your platform uses webhooks instead of streaming:
|
||||
|
||||
```python
|
||||
from flask import Flask, request
|
||||
|
||||
class YourPlatformAdapter(PlatformAdapter):
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
self.app = Flask(__name__)
|
||||
self.callback = None
|
||||
|
||||
def start_listening(self, callback):
|
||||
"""Set up webhook receiver."""
|
||||
self.callback = callback
|
||||
|
||||
@self.app.route("/webhook", methods=["POST"])
|
||||
def webhook():
|
||||
data = request.json
|
||||
message = self._normalize_message(data)
|
||||
if self.callback:
|
||||
self.callback(message)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Run Flask in separate thread
|
||||
threading.Thread(
|
||||
target=lambda: self.app.run(port=5000),
|
||||
daemon=True
|
||||
).start()
|
||||
```
|
||||
|
||||
### Error Recovery
|
||||
|
||||
Implement reconnection logic:
|
||||
|
||||
```python
|
||||
def start_listening(self, callback):
|
||||
"""Listen with automatic reconnection."""
|
||||
|
||||
def listen_with_retry():
|
||||
while self.connected:
|
||||
try:
|
||||
your_platform.stream(on_message)
|
||||
except Exception as e:
|
||||
logger.error(f"Stream error: {e}")
|
||||
if self.connected:
|
||||
logger.info("Reconnecting in 5 seconds...")
|
||||
time.sleep(5)
|
||||
|
||||
threading.Thread(target=listen_with_retry, daemon=True).start()
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
### User-Facing Documentation
|
||||
|
||||
Create `docs/platforms/yourplatform.md`:
|
||||
|
||||
```markdown
|
||||
# YourPlatform Setup Guide
|
||||
|
||||
## Prerequisites
|
||||
- YourPlatform account
|
||||
- Bot account created
|
||||
- Admin access (for some features)
|
||||
|
||||
## Setup Steps
|
||||
|
||||
1. Create bot account at https://yourplatform.com/bots
|
||||
2. Generate API token
|
||||
3. Copy `config/config.example.yaml` to `config/config.yaml`
|
||||
4. Update configuration:
|
||||
```yaml
|
||||
platform:
|
||||
type: yourplatform
|
||||
yourplatform:
|
||||
api_token: YOUR_TOKEN
|
||||
server_id: YOUR_SERVER
|
||||
```
|
||||
5. Run the bot: `python -m src.govbot`
|
||||
|
||||
## Available Features
|
||||
- Governance proposals
|
||||
- Voting in threads
|
||||
- Moderation actions
|
||||
- Admin commands
|
||||
|
||||
## Permissions Required
|
||||
- Read messages
|
||||
- Post messages
|
||||
- Manage server (for admin skills)
|
||||
```
|
||||
|
||||
## Example: Discord Adapter Skeleton
|
||||
|
||||
Here's a skeleton for a Discord adapter:
|
||||
|
||||
```python
|
||||
"""Discord platform adapter for Govbot."""
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from typing import Callable, Optional, Dict, Any, List
|
||||
import logging
|
||||
|
||||
from .base import (
|
||||
PlatformAdapter,
|
||||
PlatformMessage,
|
||||
PlatformSkill,
|
||||
SkillParameter,
|
||||
MessageVisibility,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("govbot.platforms.discord")
|
||||
|
||||
|
||||
class DiscordAdapter(PlatformAdapter):
|
||||
"""Discord implementation of platform adapter."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
|
||||
token = config.get("token")
|
||||
self.guild_id = config.get("guild_id")
|
||||
|
||||
if not token or not self.guild_id:
|
||||
raise ValueError("Discord requires 'token' and 'guild_id'")
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
|
||||
self.bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
self.token = token
|
||||
self.message_callback = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to Discord."""
|
||||
@self.bot.event
|
||||
async def on_ready():
|
||||
logger.info(f"Connected as {self.bot.user}")
|
||||
self.bot_user_id = str(self.bot.user.id)
|
||||
self.bot_username = self.bot.user.name
|
||||
self.connected = True
|
||||
|
||||
# Run bot in thread
|
||||
import threading
|
||||
threading.Thread(
|
||||
target=lambda: self.bot.run(self.token),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
# Wait for connection
|
||||
import time
|
||||
timeout = 10
|
||||
while not self.connected and timeout > 0:
|
||||
time.sleep(0.5)
|
||||
timeout -= 0.5
|
||||
|
||||
return self.connected
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from Discord."""
|
||||
import asyncio
|
||||
asyncio.run(self.bot.close())
|
||||
self.connected = False
|
||||
|
||||
def start_listening(self, callback: Callable[[PlatformMessage], None]):
|
||||
"""Listen for mentions."""
|
||||
self.message_callback = callback
|
||||
|
||||
@self.bot.event
|
||||
async def on_message(message):
|
||||
if message.author.id == self.bot.user.id:
|
||||
return
|
||||
|
||||
if self.bot.user in message.mentions:
|
||||
normalized = self._normalize_message(message)
|
||||
if self.message_callback:
|
||||
self.message_callback(normalized)
|
||||
|
||||
def post(self, message: str, thread_id: Optional[str] = None,
|
||||
reply_to_id: Optional[str] = None,
|
||||
visibility: MessageVisibility = MessageVisibility.PUBLIC) -> str:
|
||||
"""Post to Discord."""
|
||||
import asyncio
|
||||
|
||||
async def send():
|
||||
channel = self.bot.get_channel(int(thread_id))
|
||||
msg = await channel.send(message)
|
||||
return str(msg.id)
|
||||
|
||||
return asyncio.run(send())
|
||||
|
||||
def get_skills(self) -> List[PlatformSkill]:
|
||||
"""Discord-specific skills."""
|
||||
return [
|
||||
PlatformSkill(
|
||||
name="ban_user",
|
||||
description="Ban user from server",
|
||||
category="moderation",
|
||||
parameters=[
|
||||
SkillParameter("user_id", "str", "User to ban"),
|
||||
SkillParameter("reason", "str", "Ban reason"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
),
|
||||
# More Discord skills...
|
||||
]
|
||||
|
||||
def execute_skill(self, skill_name: str, parameters: Dict[str, Any],
|
||||
actor: str) -> Dict[str, Any]:
|
||||
"""Execute Discord skill."""
|
||||
# Implementation...
|
||||
pass
|
||||
|
||||
def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get Discord user info."""
|
||||
# Implementation...
|
||||
pass
|
||||
|
||||
def format_thread_url(self, thread_id: str) -> str:
|
||||
"""Format Discord channel URL."""
|
||||
return f"https://discord.com/channels/{self.guild_id}/{thread_id}"
|
||||
|
||||
def _normalize_message(self, msg: discord.Message) -> PlatformMessage:
|
||||
"""Convert Discord message to PlatformMessage."""
|
||||
return PlatformMessage(
|
||||
id=str(msg.id),
|
||||
text=msg.content,
|
||||
author_id=str(msg.author.id),
|
||||
author_handle=msg.author.name,
|
||||
timestamp=msg.created_at,
|
||||
thread_id=str(msg.channel.id),
|
||||
reply_to_id=str(msg.reference.message_id) if msg.reference else None,
|
||||
visibility=MessageVisibility.PUBLIC,
|
||||
mentions_bot=True,
|
||||
raw_data=msg,
|
||||
)
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Review the Mastodon adapter for a complete example
|
||||
- Check the base PlatformAdapter for interface documentation
|
||||
- Look at test files for testing patterns
|
||||
- Ask questions in project discussions
|
||||
|
||||
## Contributing Your Adapter
|
||||
|
||||
Once your adapter works:
|
||||
|
||||
1. Add comprehensive docstrings
|
||||
2. Write tests
|
||||
3. Document setup in `docs/platforms/`
|
||||
4. Update README.md with supported platforms
|
||||
5. Submit a pull request
|
||||
|
||||
Thank you for extending Govbot to new platforms! 🎉
|
||||
247
QUICKSTART.md
Normal file
247
QUICKSTART.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Govbot Quick Start Guide
|
||||
|
||||
This guide will help you get Govbot up and running.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11 or higher
|
||||
- `llm` CLI tool installed (`pip install llm`)
|
||||
- Ollama installed with a model (e.g., `ollama pull llama3.2`) OR API keys for cloud models
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
Using `uv` (faster, recommended):
|
||||
```bash
|
||||
# Install uv if you don't have it
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Install dependencies
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
Using regular pip:
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 2. Configure the Bot
|
||||
|
||||
```bash
|
||||
# Copy example config
|
||||
cp config/config.example.yaml config/config.yaml
|
||||
|
||||
# Edit with your settings
|
||||
# At minimum, update the AI model configuration
|
||||
nano config/config.yaml
|
||||
```
|
||||
|
||||
For local models with Ollama:
|
||||
```yaml
|
||||
ai:
|
||||
default_model: llama3.2 # or whatever model you have
|
||||
```
|
||||
|
||||
For cloud models:
|
||||
```yaml
|
||||
ai:
|
||||
default_model: gpt-4 # or claude-3-sonnet, etc.
|
||||
```
|
||||
|
||||
Make sure the `llm` CLI is configured for your chosen model:
|
||||
```bash
|
||||
# Test llm
|
||||
llm "Hello, how are you?"
|
||||
|
||||
# Configure for cloud models if needed
|
||||
llm keys set openai # for OpenAI
|
||||
llm keys set anthropic # for Anthropic Claude
|
||||
```
|
||||
|
||||
### 3. Initialize the Database
|
||||
|
||||
The database will be created automatically on first run, but you can verify:
|
||||
|
||||
```bash
|
||||
python -c "from src.govbot.db.models import init_db; init_db('govbot.db'); print('Database initialized!')"
|
||||
```
|
||||
|
||||
## Testing Without Mastodon
|
||||
|
||||
The CLI allows you to test all governance features without connecting to Mastodon:
|
||||
|
||||
```bash
|
||||
# Run the interactive CLI
|
||||
python -m src.govbot
|
||||
```
|
||||
|
||||
Try these commands:
|
||||
|
||||
```
|
||||
# View the constitution
|
||||
constitution
|
||||
|
||||
# Ask a constitutional question
|
||||
query What are the rules for creating a proposal?
|
||||
|
||||
# Create a test proposal
|
||||
propose We should update the moderation guidelines
|
||||
|
||||
# Check active processes
|
||||
processes
|
||||
|
||||
# Vote on a process (use the ID from processes command)
|
||||
vote 1 agree
|
||||
|
||||
# Check process status
|
||||
status 1
|
||||
|
||||
# View recent actions
|
||||
actions
|
||||
```
|
||||
|
||||
## Testing the Constitutional Reasoner
|
||||
|
||||
You can test the constitutional reasoning engine directly:
|
||||
|
||||
```bash
|
||||
python -m src.govbot.governance.constitution "What voting thresholds are required for different proposal types?"
|
||||
```
|
||||
|
||||
## Understanding the Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
1. **Constitution (constitution.md)**: The governance rules in natural language
|
||||
2. **Database (govbot.db)**: SQLite database storing all governance state
|
||||
3. **AI Agent (src/govbot/agent.py)**: Interprets requests and orchestrates actions
|
||||
4. **Primitives (src/govbot/governance/primitives.py)**: Low-level governance operations
|
||||
5. **Constitutional Reasoner (src/govbot/governance/constitution.py)**: RAG system for understanding the constitution
|
||||
6. **Scheduler (src/govbot/scheduler.py)**: Background tasks for deadlines and reminders
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
User Request
|
||||
↓
|
||||
AI Agent parses intent
|
||||
↓
|
||||
Queries Constitution (RAG)
|
||||
↓
|
||||
Plans primitive actions
|
||||
↓
|
||||
Executes with audit logging
|
||||
↓
|
||||
Returns response
|
||||
```
|
||||
|
||||
## Example: Creating a Proposal
|
||||
|
||||
Let's walk through what happens when you create a proposal:
|
||||
|
||||
```bash
|
||||
$ python -m src.govbot
|
||||
@testuser> propose We should add weekly community meetings
|
||||
```
|
||||
|
||||
The bot will:
|
||||
1. **Parse** your intent (creating a proposal)
|
||||
2. **Query** the constitution about proposal rules
|
||||
3. **Determine** this is a standard proposal (6 days, simple majority)
|
||||
4. **Create** a governance process in the database
|
||||
5. **Schedule** a reminder for the deadline
|
||||
6. **Respond** with the proposal details and how to vote
|
||||
|
||||
You can then vote:
|
||||
```
|
||||
@testuser> vote 1 agree
|
||||
```
|
||||
|
||||
And check status:
|
||||
```
|
||||
@testuser> status 1
|
||||
```
|
||||
|
||||
The scheduler (running in background) will automatically close the proposal after 6 days and count votes.
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Customizing the Constitution
|
||||
|
||||
Edit `constitution.md` to match your community's governance needs. The AI will adapt to whatever you write!
|
||||
|
||||
Key things to include:
|
||||
- Proposal types and their requirements
|
||||
- Voting thresholds
|
||||
- Member rights and responsibilities
|
||||
- Administrative procedures
|
||||
- Safety mechanisms (veto, appeals)
|
||||
|
||||
### Connecting to Mastodon
|
||||
|
||||
To connect to a real Mastodon instance, you'll need to:
|
||||
|
||||
1. Create a bot account on your instance
|
||||
2. Register an application to get OAuth credentials
|
||||
3. Update `config/config.yaml` with your Mastodon settings
|
||||
4. Implement the Mastodon streaming listener (currently a stub in `bot.py`)
|
||||
|
||||
See [MASTODON.md](MASTODON.md) for detailed instructions (coming soon).
|
||||
|
||||
### Adding Custom Governance Skills
|
||||
|
||||
You can extend the bot with custom Mastodon-specific actions:
|
||||
|
||||
1. Add new primitives in `src/govbot/governance/primitives.py`
|
||||
2. Update the agent's planning logic in `src/govbot/agent.py`
|
||||
3. Update the constitution to reference new capabilities
|
||||
|
||||
### Running in Production
|
||||
|
||||
For production deployment:
|
||||
|
||||
1. Set up proper logging (see `config.yaml`)
|
||||
2. Use a systemd service or supervisor to keep it running
|
||||
3. Set up monitoring for the database and process queue
|
||||
4. Consider running on a dedicated server or container
|
||||
5. Implement backup procedures for the database
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Could not parse request" error
|
||||
- Check that `llm` CLI is working: `llm "test"`
|
||||
- Verify your model is installed: `ollama list` or check API keys
|
||||
- Try a simpler request to test
|
||||
|
||||
### "Constitution not found" error
|
||||
- Make sure `constitution.md` exists in the root directory
|
||||
- Check the path in `config/config.yaml`
|
||||
|
||||
### Database errors
|
||||
- Delete `govbot.db` and let it reinitialize
|
||||
- Check file permissions
|
||||
|
||||
### Model not responding
|
||||
- Test llm directly: `llm -m llama3.2 "hello"`
|
||||
- Check Ollama is running: `ollama list`
|
||||
- For cloud models, verify API keys: `llm keys list`
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check the [README.md](README.md) for architecture details
|
||||
- Review the constitution examples
|
||||
- Look at the code - it's designed to be readable!
|
||||
- Open an issue if you find bugs
|
||||
|
||||
## Philosophy
|
||||
|
||||
Govbot is designed around a few key principles:
|
||||
|
||||
1. **Agentic, not procedural**: The bot interprets natural language constitutions rather than hard-coding governance procedures
|
||||
2. **Transparent**: All actions are logged with constitutional reasoning
|
||||
3. **Reversible**: The community can override bot decisions
|
||||
4. **Flexible**: Works with any constitution you write
|
||||
5. **Democratic**: Enables collective governance within social platforms
|
||||
|
||||
Have fun governing! 🏛️
|
||||
107
README.md
Normal file
107
README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Govbot
|
||||
|
||||
An agentic governance bot for democratic communities that interprets natural language constitutions and facilitates collective decision-making across social platforms.
|
||||
|
||||
## Overview
|
||||
|
||||
Govbot is designed to:
|
||||
- Read and interpret governance constitutions written in natural language
|
||||
- Facilitate proposals, voting, and decision-making on social platforms
|
||||
- Execute administrative actions based on constitutional rules
|
||||
- Maintain an audit trail of all governance actions
|
||||
- Support both local (Ollama) and cloud AI models
|
||||
- **Work across multiple platforms** (Mastodon, Discord, Telegram, etc.)
|
||||
|
||||
## Features
|
||||
|
||||
- **Agentic Architecture**: The bot dynamically interprets constitutional rules rather than hard-coding governance procedures
|
||||
- **RAG-based Constitutional Reasoning**: Uses retrieval-augmented generation to understand and apply governance rules
|
||||
- **Platform-Agnostic**: Same governance logic works across Mastodon, Discord, Telegram, Matrix, and more
|
||||
- **Reversible Actions**: All actions are logged and can be reversed through constitutional processes
|
||||
- **Temporal Awareness**: Handles multi-day governance processes with deadlines and reminders
|
||||
- **Supermajority Veto**: Built-in safety mechanism allowing members to halt problematic actions
|
||||
- **Platform Skills**: Can perform admin actions, moderation, and platform-specific governance
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- ✅ **Mastodon** - Full implementation with streaming, admin, and moderation
|
||||
- 🚧 **Discord** - Coming soon (see [PLATFORMS.md](PLATFORMS.md) for implementation guide)
|
||||
- 🚧 **Telegram** - Coming soon
|
||||
- 🚧 **Matrix** - Planned
|
||||
|
||||
Want to add a platform? See [PLATFORMS.md](PLATFORMS.md) for the implementation guide!
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Governance Core (Platform-Agnostic)│
|
||||
│ - Constitutional Reasoning (RAG) │
|
||||
│ - AI Agent & Planning │
|
||||
│ - Action Primitives │
|
||||
│ - Temporal Scheduler │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌───────▼───────┐
|
||||
│ Platform │
|
||||
│ Adapter │ ← Abstraction layer
|
||||
└───────┬───────┘
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
│ │ │
|
||||
┌────▼────┐┌───▼────┐┌───▼────┐
|
||||
│Mastodon ││Discord ││Telegram│
|
||||
│ Adapter ││ Adapter││ Adapter│
|
||||
└─────────┘└────────┘└────────┘
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies (using uv for faster installation)
|
||||
uv pip install -e .
|
||||
|
||||
# For development
|
||||
uv pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Copy `config/config.example.yaml` to `config/config.yaml`
|
||||
2. Edit with your settings:
|
||||
- Mastodon instance URL and credentials
|
||||
- AI model preferences (local/cloud)
|
||||
- Path to your constitution file
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run the bot
|
||||
python -m src.govbot.bot
|
||||
|
||||
# Query the constitution
|
||||
python -m src.govbot.governance.constitution "What are the rules for proposals?"
|
||||
```
|
||||
|
||||
## Constitution Format
|
||||
|
||||
Your constitution should be a markdown file that describes:
|
||||
- Governance processes (proposals, voting, etc.)
|
||||
- Decision-making thresholds
|
||||
- Member rights and responsibilities
|
||||
- Administrative procedures
|
||||
- Safety mechanisms (veto, appeals, etc.)
|
||||
|
||||
See `constitution.md` for an example based on Social.coop's bylaws.
|
||||
|
||||
## Development Status
|
||||
|
||||
This is early-stage software. Current phase: Core infrastructure and agentic reasoning engine.
|
||||
|
||||
## License
|
||||
|
||||
[To be determined]
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is in early development. Contributions and feedback welcome!
|
||||
43
config/config.example.yaml
Normal file
43
config/config.example.yaml
Normal file
@@ -0,0 +1,43 @@
|
||||
platform:
|
||||
# Platform type: mastodon, discord, telegram, mock
|
||||
type: mastodon
|
||||
|
||||
# Mastodon configuration (if using Mastodon)
|
||||
mastodon:
|
||||
instance_url: https://your-mastodon-instance.social
|
||||
client_id: your_client_id_here
|
||||
client_secret: your_client_secret_here
|
||||
access_token: your_access_token_here
|
||||
bot_username: govbot
|
||||
|
||||
# Discord configuration (for future use)
|
||||
# discord:
|
||||
# token: your_discord_bot_token
|
||||
# guild_id: your_server_id
|
||||
|
||||
# Telegram configuration (for future use)
|
||||
# telegram:
|
||||
# bot_token: your_telegram_bot_token
|
||||
# group_id: your_group_id
|
||||
|
||||
ai:
|
||||
# Use 'llama3.2' for Ollama local models
|
||||
# Use 'gpt-4' for OpenAI
|
||||
# Use 'claude-3-sonnet' for Anthropic
|
||||
# Or leave as null to use llm default
|
||||
default_model: llama3.2
|
||||
fallback_model: null
|
||||
temperature: 0.7
|
||||
max_tokens: null
|
||||
|
||||
governance:
|
||||
constitution_path: constitution.md
|
||||
db_path: govbot.db
|
||||
default_veto_threshold: 0.67 # 2/3 supermajority
|
||||
enable_auto_execution: true
|
||||
require_confirmation_for:
|
||||
- admin_action
|
||||
- moderation
|
||||
|
||||
debug: false
|
||||
log_level: INFO
|
||||
190
constitution.md
Normal file
190
constitution.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Governance Constitution
|
||||
|
||||
This constitution defines how collective governance operates within this Mastodon instance.
|
||||
|
||||
## Article 1: Governance Bot
|
||||
|
||||
### Section 1.1: Bot Authority
|
||||
The governance bot (@govbot) facilitates democratic decision-making according to this constitution. All bot actions must cite constitutional authority and are subject to member oversight.
|
||||
|
||||
### Section 1.2: Audit and Oversight
|
||||
- All bot actions are logged with constitutional reasoning
|
||||
- Members can review the audit log at any time
|
||||
- Any action can be challenged through the appeal process (Article 6)
|
||||
|
||||
### Section 1.3: Emergency Halt
|
||||
If the bot acts contrary to this constitution's spirit, any member may call for an emergency review. A supermajority (2/3) vote can immediately halt or reverse bot actions.
|
||||
|
||||
## Article 2: Membership and Rights
|
||||
|
||||
### Section 2.1: Member Rights
|
||||
All instance members have equal rights to:
|
||||
- Propose governance changes
|
||||
- Vote on proposals
|
||||
- Access governance records
|
||||
- Appeal bot decisions
|
||||
- Request constitutional interpretation
|
||||
|
||||
### Section 2.2: Member Responsibilities
|
||||
Members are expected to:
|
||||
- Participate in good faith
|
||||
- Follow the code of conduct
|
||||
- Review governance proposals when feasible
|
||||
|
||||
## Article 3: Proposals
|
||||
|
||||
### Section 3.1: Proposal Types
|
||||
|
||||
**Standard Proposals** address routine governance matters:
|
||||
- Discussion period: 6 days minimum
|
||||
- Passage threshold: More Agree than Disagree votes
|
||||
- Abstentions do not count against passage
|
||||
|
||||
**Urgent Proposals** address time-sensitive matters:
|
||||
- Must be labeled "URGENT" with justification
|
||||
- Discussion period: 2 days minimum
|
||||
- Passage threshold: Same as standard proposals
|
||||
- Any member may challenge urgency designation
|
||||
|
||||
**Constitutional Amendments** modify this constitution:
|
||||
- Discussion period: 10 days minimum
|
||||
- Passage threshold: At least 3 times as many Agree as Disagree votes
|
||||
- Higher bar reflects importance of constitutional stability
|
||||
|
||||
### Section 3.2: Proposal Creation
|
||||
Any member may create a proposal by:
|
||||
1. Mentioning @govbot with proposal text
|
||||
2. Specifying proposal type if not standard
|
||||
3. Providing rationale and context
|
||||
|
||||
The bot will:
|
||||
- Confirm receipt and proposal type
|
||||
- Set appropriate deadline based on type
|
||||
- Open discussion thread
|
||||
- Track votes
|
||||
- Announce result when deadline passes
|
||||
|
||||
### Section 3.3: Block Votes
|
||||
Members may cast a Block vote to signal fundamental disagreement (e.g., ethical concerns, constitutional violations).
|
||||
|
||||
For proposals with Blocks to pass:
|
||||
- Require at least 9 times more Agree votes than combined Disagree and Block votes
|
||||
- Block votes should include explanation of fundamental concern
|
||||
- Overriding blocks requires strong consensus
|
||||
|
||||
## Article 4: Voting
|
||||
|
||||
### Section 4.1: Vote Types
|
||||
- **Agree**: Support the proposal
|
||||
- **Disagree**: Oppose the proposal
|
||||
- **Abstain**: Counted for quorum but not in thresholds
|
||||
- **Block**: Fundamental disagreement (see Article 3.3)
|
||||
|
||||
### Section 4.2: Voting Process
|
||||
- Members vote by replying to proposal thread
|
||||
- Votes can be changed before deadline
|
||||
- Bot tracks and counts votes automatically
|
||||
- Final tally posted when deadline passes
|
||||
|
||||
### Section 4.3: Vote Privacy
|
||||
Votes are public by default (visible in threads). This promotes transparency and accountability.
|
||||
|
||||
## Article 5: Administrative Actions
|
||||
|
||||
### Section 5.1: Code of Conduct Changes
|
||||
Changes to the instance Code of Conduct:
|
||||
- Require constitutional amendment process (10 days, 3x threshold)
|
||||
- Bot can update CoC text upon passage
|
||||
- Previous version archived for reference
|
||||
|
||||
### Section 5.2: Moderation Actions
|
||||
Emergency moderation (spam, harassment, illegal content):
|
||||
- Any moderator can take immediate action
|
||||
- Action must be reported to community within 24 hours
|
||||
- Community can review and reverse through standard proposal
|
||||
- Bot can execute moderation actions when authorized
|
||||
|
||||
### Section 5.3: Admin Powers
|
||||
Administrative access (server configuration, user roles):
|
||||
- Changes require standard proposal
|
||||
- Bot can transfer admin powers when authorized
|
||||
- Maintains audit log of all admin actions
|
||||
|
||||
### Section 5.4: Instance Policies
|
||||
Changes to instance policies (federation, content warnings, etc.):
|
||||
- Require standard proposal process
|
||||
- Bot implements approved policy changes
|
||||
- Policy history maintained
|
||||
|
||||
## Article 6: Appeals and Clarification
|
||||
|
||||
### Section 6.1: Constitutional Interpretation
|
||||
Members may ask the bot to interpret constitutional provisions:
|
||||
- Bot provides interpretation with reasoning
|
||||
- Non-binding but serves as guidance
|
||||
- Community can override through constitutional amendment
|
||||
|
||||
### Section 6.2: Ambiguity Resolution
|
||||
If the bot encounters serious constitutional ambiguity:
|
||||
- Bot posts question seeking clarification
|
||||
- Community discusses and provides guidance
|
||||
- May result in constitutional amendment for clarity
|
||||
|
||||
### Section 6.3: Appeal Process
|
||||
To appeal a bot action or interpretation:
|
||||
1. Member posts appeal with reasoning
|
||||
2. Community discusses (3 day minimum)
|
||||
3. Standard proposal vote on override
|
||||
4. Supermajority (2/3) can immediately halt pending appeals
|
||||
|
||||
## Article 7: Precedent and Evolution
|
||||
|
||||
### Section 7.1: Governance Precedent
|
||||
Bot maintains record of:
|
||||
- All proposals and outcomes
|
||||
- Constitutional interpretations
|
||||
- Clarifications provided
|
||||
- Appeals and resolutions
|
||||
|
||||
These create guidance for future similar cases.
|
||||
|
||||
### Section 7.2: Constitutional Amendments
|
||||
This constitution can be amended through the constitutional amendment process (Article 3.1). Amendments should maintain:
|
||||
- Democratic principles
|
||||
- Member rights and protections
|
||||
- Audit and oversight mechanisms
|
||||
- Appeal processes
|
||||
|
||||
### Section 7.3: Emergency Provisions
|
||||
In case of bot malfunction or constitutional crisis:
|
||||
- Instance admin retains ultimate technical control
|
||||
- Community can coordinate emergency response
|
||||
- Constitutional amendments can establish recovery procedures
|
||||
|
||||
## Article 8: Temporal Governance
|
||||
|
||||
### Section 8.1: Deadlines and Timing
|
||||
- All time periods measured in calendar days
|
||||
- Deadlines posted in UTC with local time conversions
|
||||
- Bot sends reminders before deadlines
|
||||
- Extensions require proposal and approval
|
||||
|
||||
### Section 8.2: Quorum
|
||||
No quorum required for proposals to pass (to avoid voter fatigue). High thresholds (3x, 9x) serve similar function.
|
||||
|
||||
### Section 8.3: Concurrent Processes
|
||||
Multiple governance processes can run simultaneously. Bot tracks each independently.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This constitution is designed to be interpreted by an AI agent. Key principles:
|
||||
|
||||
1. **Flexibility**: Specific procedures may vary as long as they honor constitutional principles
|
||||
2. **Transparency**: All bot actions must be explainable and auditable
|
||||
3. **Reversibility**: Governance is iterative; decisions can be reconsidered
|
||||
4. **Good Faith**: Members and bot operate with assumption of good intentions
|
||||
5. **Safety**: Multiple mechanisms prevent bot malfunction or misuse
|
||||
|
||||
The bot should seek to honor the spirit of this constitution, not just its letter. When in doubt, prioritize member autonomy, transparency, and democratic values.
|
||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[project]
|
||||
name = "govbot"
|
||||
version = "0.1.0"
|
||||
description = "An agentic Mastodon bot for collective governance"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mastodon.py>=1.8.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"llm>=0.15.0",
|
||||
"sqlite-utils>=3.35.0",
|
||||
"python-dateutil>=2.8.0",
|
||||
"pyyaml>=6.0.0",
|
||||
"schedule>=1.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"black>=23.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py311']
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = "test_*.py"
|
||||
python_functions = "test_*"
|
||||
0
src/govbot/__init__.py
Normal file
0
src/govbot/__init__.py
Normal file
8
src/govbot/__main__.py
Normal file
8
src/govbot/__main__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Makes the package runnable with: python -m src.govbot
|
||||
"""
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
562
src/govbot/agent.py
Normal file
562
src/govbot/agent.py
Normal file
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
AI Agent Orchestration for Governance Bot.
|
||||
|
||||
This is the core agentic system that:
|
||||
1. Receives governance requests
|
||||
2. Consults the constitution (via RAG)
|
||||
3. Plans appropriate actions
|
||||
4. Executes using primitives
|
||||
5. Maintains audit trail
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .governance.constitution import ConstitutionalReasoner
|
||||
from .governance.primitives import GovernancePrimitives
|
||||
from .db import queries
|
||||
|
||||
|
||||
class GovernanceAgent:
|
||||
"""
|
||||
The AI agent that interprets requests and orchestrates governance actions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_session: Session,
|
||||
constitution_path: str,
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the governance agent.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
constitution_path: Path to constitution file
|
||||
model: LLM model to use (None for default)
|
||||
"""
|
||||
self.db = db_session
|
||||
self.constitution = ConstitutionalReasoner(constitution_path, model)
|
||||
self.primitives = GovernancePrimitives(db_session)
|
||||
self.model = model
|
||||
|
||||
def process_request(
|
||||
self, request: str, actor: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Process a governance request from a user.
|
||||
|
||||
This is the main agentic loop:
|
||||
1. Parse intent
|
||||
2. Consult constitution
|
||||
3. Plan actions
|
||||
4. Execute with audit trail
|
||||
5. Return response
|
||||
|
||||
Args:
|
||||
request: Natural language request from user
|
||||
actor: Who made the request (Mastodon handle)
|
||||
context: Optional context (thread ID, etc.)
|
||||
|
||||
Returns:
|
||||
Dict with 'response', 'actions_taken', 'process_id', etc.
|
||||
"""
|
||||
# Step 1: Parse intent
|
||||
intent = self._parse_intent(request, actor)
|
||||
|
||||
if intent.get("error"):
|
||||
return {"response": intent["error"], "success": False}
|
||||
|
||||
# Step 2: Consult constitution
|
||||
constitutional_guidance = self.constitution.query(
|
||||
question=intent["intent_description"],
|
||||
context=f"Actor: {actor}, Request: {request}",
|
||||
)
|
||||
|
||||
# Step 3: Check for ambiguity
|
||||
if constitutional_guidance.get("confidence") == "low":
|
||||
return self._handle_ambiguity(
|
||||
request, actor, constitutional_guidance
|
||||
)
|
||||
|
||||
# Step 4: Plan actions
|
||||
action_plan = self._plan_actions(
|
||||
intent, constitutional_guidance, actor, context
|
||||
)
|
||||
|
||||
# Step 5: Execute plan
|
||||
result = self._execute_plan(action_plan, actor)
|
||||
|
||||
return result
|
||||
|
||||
def _parse_intent(self, request: str, actor: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Use AI to parse user intent from natural language.
|
||||
|
||||
Args:
|
||||
request: User's request
|
||||
actor: Who made the request
|
||||
|
||||
Returns:
|
||||
Dict with 'intent_type', 'intent_description', 'parameters'
|
||||
"""
|
||||
prompt = f"""Parse this governance request and extract structured information.
|
||||
|
||||
REQUEST: "{request}"
|
||||
ACTOR: {actor}
|
||||
|
||||
Identify:
|
||||
1. Intent type (e.g., "create_proposal", "cast_vote", "query_constitution", "appeal", etc.)
|
||||
2. Clear description of what the user wants
|
||||
3. Key parameters extracted from request
|
||||
|
||||
Respond with JSON:
|
||||
{{
|
||||
"intent_type": "the type of intent",
|
||||
"intent_description": "clear description of what user wants",
|
||||
"parameters": {{
|
||||
"key": "value"
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
result = self._call_llm(prompt)
|
||||
parsed = self._extract_json(result)
|
||||
return parsed
|
||||
except Exception as e:
|
||||
return {"error": f"Could not parse request: {str(e)}"}
|
||||
|
||||
def _plan_actions(
|
||||
self,
|
||||
intent: Dict[str, Any],
|
||||
constitutional_guidance: Dict[str, Any],
|
||||
actor: str,
|
||||
context: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Plan the sequence of primitive actions to fulfill the intent.
|
||||
|
||||
Args:
|
||||
intent: Parsed intent
|
||||
constitutional_guidance: Constitutional interpretation
|
||||
actor: Who initiated
|
||||
context: Additional context
|
||||
|
||||
Returns:
|
||||
Action plan dictionary
|
||||
"""
|
||||
intent_type = intent.get("intent_type")
|
||||
|
||||
# Route to specific planning function based on intent
|
||||
if intent_type == "create_proposal":
|
||||
return self._plan_proposal_creation(
|
||||
intent, constitutional_guidance, actor, context
|
||||
)
|
||||
elif intent_type == "cast_vote":
|
||||
return self._plan_vote_casting(
|
||||
intent, constitutional_guidance, actor, context
|
||||
)
|
||||
elif intent_type == "query_constitution":
|
||||
return self._plan_constitutional_query(
|
||||
intent, constitutional_guidance, actor
|
||||
)
|
||||
elif intent_type == "appeal":
|
||||
return self._plan_appeal(
|
||||
intent, constitutional_guidance, actor, context
|
||||
)
|
||||
else:
|
||||
# Generic planning using AI
|
||||
return self._plan_generic(
|
||||
intent, constitutional_guidance, actor, context
|
||||
)
|
||||
|
||||
def _plan_proposal_creation(
|
||||
self,
|
||||
intent: Dict[str, Any],
|
||||
constitutional_guidance: Dict[str, Any],
|
||||
actor: str,
|
||||
context: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Plan actions for creating a proposal"""
|
||||
params = intent.get("parameters", {})
|
||||
proposal_text = params.get("proposal_text", intent.get("intent_description"))
|
||||
|
||||
# Interpret proposal to determine type and requirements
|
||||
proposal_info = self.constitution.interpret_proposal(proposal_text)
|
||||
|
||||
# Build action plan
|
||||
plan = {
|
||||
"intent_type": "create_proposal",
|
||||
"constitutional_basis": constitutional_guidance.get("citations", []),
|
||||
"actions": [
|
||||
{
|
||||
"primitive": "create_process",
|
||||
"args": {
|
||||
"process_type": f"{proposal_info['proposal_type']}_proposal",
|
||||
"creator": actor,
|
||||
"deadline_days": proposal_info.get("discussion_period_days", 6),
|
||||
"constitutional_basis": str(constitutional_guidance.get("citations")),
|
||||
"initial_state": {
|
||||
"proposal_text": proposal_text,
|
||||
"proposal_type": proposal_info["proposal_type"],
|
||||
"voting_threshold": proposal_info.get("voting_threshold"),
|
||||
"votes": {},
|
||||
},
|
||||
"mastodon_thread_id": context.get("thread_id")
|
||||
if context
|
||||
else None,
|
||||
},
|
||||
},
|
||||
{
|
||||
"primitive": "schedule_reminder",
|
||||
"args": {
|
||||
"when": "deadline", # Will be calculated from process deadline
|
||||
"message": f"Proposal by {actor} has reached its deadline. Counting votes.",
|
||||
},
|
||||
},
|
||||
],
|
||||
"response_template": f"""Proposal created: {proposal_text[:100]}...
|
||||
|
||||
Type: {proposal_info['proposal_type']}
|
||||
Discussion period: {proposal_info.get('discussion_period_days')} days
|
||||
Voting threshold: {proposal_info.get('voting_threshold')}
|
||||
|
||||
Constitutional basis: {', '.join(constitutional_guidance.get('citations', []))}
|
||||
|
||||
Reply with 'agree', 'disagree', 'abstain', or 'block' to vote.
|
||||
Process ID: {{process_id}}
|
||||
""",
|
||||
}
|
||||
|
||||
return plan
|
||||
|
||||
def _plan_vote_casting(
|
||||
self,
|
||||
intent: Dict[str, Any],
|
||||
constitutional_guidance: Dict[str, Any],
|
||||
actor: str,
|
||||
context: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Plan actions for casting a vote"""
|
||||
params = intent.get("parameters", {})
|
||||
vote_type = params.get("vote_type", "agree").lower()
|
||||
process_id = params.get("process_id")
|
||||
|
||||
if not process_id:
|
||||
return {
|
||||
"error": "Could not identify which proposal to vote on. Please reply to a proposal thread."
|
||||
}
|
||||
|
||||
plan = {
|
||||
"intent_type": "cast_vote",
|
||||
"constitutional_basis": constitutional_guidance.get("citations", []),
|
||||
"actions": [
|
||||
{
|
||||
"primitive": "update_process_state",
|
||||
"args": {
|
||||
"process_id": process_id,
|
||||
"state_updates": {
|
||||
f"votes.{actor}": {
|
||||
"vote": vote_type,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
},
|
||||
"actor": actor,
|
||||
},
|
||||
}
|
||||
],
|
||||
"response_template": f"""Vote recorded: {vote_type}
|
||||
|
||||
Voter: {actor}
|
||||
Process: {{process_id}}
|
||||
""",
|
||||
}
|
||||
|
||||
return plan
|
||||
|
||||
def _plan_constitutional_query(
|
||||
self,
|
||||
intent: Dict[str, Any],
|
||||
constitutional_guidance: Dict[str, Any],
|
||||
actor: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Plan response for constitutional query"""
|
||||
return {
|
||||
"intent_type": "query_constitution",
|
||||
"actions": [], # No state changes needed
|
||||
"response_template": f"""Constitutional Interpretation:
|
||||
|
||||
{constitutional_guidance['answer']}
|
||||
|
||||
Citations: {', '.join(constitutional_guidance.get('citations', []))}
|
||||
Confidence: {constitutional_guidance.get('confidence', 'medium')}
|
||||
""",
|
||||
}
|
||||
|
||||
def _plan_appeal(
|
||||
self,
|
||||
intent: Dict[str, Any],
|
||||
constitutional_guidance: Dict[str, Any],
|
||||
actor: str,
|
||||
context: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Plan actions for an appeal"""
|
||||
params = intent.get("parameters", {})
|
||||
action_id = params.get("action_id")
|
||||
|
||||
plan = {
|
||||
"intent_type": "appeal",
|
||||
"constitutional_basis": constitutional_guidance.get("citations", []),
|
||||
"actions": [
|
||||
{
|
||||
"primitive": "create_process",
|
||||
"args": {
|
||||
"process_type": "appeal",
|
||||
"creator": actor,
|
||||
"deadline_days": 3,
|
||||
"constitutional_basis": "Article 6: Appeals",
|
||||
"initial_state": {
|
||||
"appealed_action_id": action_id,
|
||||
"appellant": actor,
|
||||
"votes": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"response_template": f"""Appeal initiated by {actor}
|
||||
|
||||
Appealing action: {{action_id}}
|
||||
Discussion period: 3 days
|
||||
|
||||
Community members can vote on whether to override the action.
|
||||
""",
|
||||
}
|
||||
|
||||
return plan
|
||||
|
||||
def _plan_generic(
|
||||
self,
|
||||
intent: Dict[str, Any],
|
||||
constitutional_guidance: Dict[str, Any],
|
||||
actor: str,
|
||||
context: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Use AI to plan generic actions"""
|
||||
# This is a fallback for intents we haven't explicitly coded
|
||||
prompt = f"""Based on this intent and constitutional guidance, plan the primitive actions needed.
|
||||
|
||||
INTENT: {json.dumps(intent, indent=2)}
|
||||
|
||||
CONSTITUTIONAL GUIDANCE: {json.dumps(constitutional_guidance, indent=2)}
|
||||
|
||||
Available primitives:
|
||||
- create_process(process_type, creator, deadline_days, constitutional_basis, initial_state)
|
||||
- update_process_state(process_id, state_updates, actor)
|
||||
- store_record(record_type, data, actor, reasoning, citation)
|
||||
- schedule_reminder(when, message)
|
||||
|
||||
Plan the actions as JSON:
|
||||
{{
|
||||
"actions": [
|
||||
{{"primitive": "name", "args": {{...}}}}
|
||||
],
|
||||
"response_template": "Message to send user"
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
result = self._call_llm(prompt)
|
||||
plan = self._extract_json(result)
|
||||
plan["intent_type"] = intent.get("intent_type")
|
||||
plan["constitutional_basis"] = constitutional_guidance.get("citations", [])
|
||||
return plan
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": f"Could not plan actions: {str(e)}",
|
||||
"intent": intent,
|
||||
"guidance": constitutional_guidance,
|
||||
}
|
||||
|
||||
def _execute_plan(
|
||||
self, plan: Dict[str, Any], actor: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute the planned actions using primitives.
|
||||
|
||||
Args:
|
||||
plan: Action plan
|
||||
actor: Who initiated
|
||||
|
||||
Returns:
|
||||
Execution result
|
||||
"""
|
||||
if plan.get("error"):
|
||||
return {"response": plan["error"], "success": False}
|
||||
|
||||
executed_actions = []
|
||||
process_id = None
|
||||
|
||||
try:
|
||||
for action in plan.get("actions", []):
|
||||
primitive = action["primitive"]
|
||||
args = action["args"]
|
||||
|
||||
# Get the primitive function
|
||||
if hasattr(self.primitives, primitive):
|
||||
func = getattr(self.primitives, primitive)
|
||||
|
||||
# Handle special cases like deadline calculation
|
||||
if "when" in args and args["when"] == "deadline":
|
||||
# Calculate from process deadline
|
||||
if process_id:
|
||||
process = queries.get_process(self.db, process_id)
|
||||
args["when"] = process.deadline
|
||||
|
||||
result = func(**args)
|
||||
|
||||
# Track process ID for response
|
||||
if primitive == "create_process":
|
||||
process_id = result
|
||||
|
||||
executed_actions.append(
|
||||
{"primitive": primitive, "result": result}
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown primitive: {primitive}")
|
||||
|
||||
# Build response
|
||||
response_template = plan.get("response_template", "Action completed.")
|
||||
response = response_template.format(
|
||||
process_id=process_id, action_id=executed_actions[0].get("result")
|
||||
if executed_actions
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"response": response,
|
||||
"success": True,
|
||||
"process_id": process_id,
|
||||
"actions_taken": executed_actions,
|
||||
"constitutional_basis": plan.get("constitutional_basis"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"response": f"Error executing actions: {str(e)}",
|
||||
"success": False,
|
||||
"partial_actions": executed_actions,
|
||||
}
|
||||
|
||||
def _handle_ambiguity(
|
||||
self,
|
||||
request: str,
|
||||
actor: str,
|
||||
constitutional_guidance: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle constitutional ambiguity by requesting clarification.
|
||||
|
||||
Args:
|
||||
request: Original request
|
||||
actor: Who made request
|
||||
constitutional_guidance: The ambiguous guidance
|
||||
|
||||
Returns:
|
||||
Response explaining ambiguity
|
||||
"""
|
||||
ambiguity = constitutional_guidance.get("ambiguity", "Constitutional interpretation unclear")
|
||||
|
||||
# Create clarification request
|
||||
clarification = queries.create_clarification(
|
||||
session=self.db,
|
||||
question=f"Ambiguity in request '{request}': {ambiguity}",
|
||||
)
|
||||
|
||||
response = f"""I encountered constitutional ambiguity in processing your request.
|
||||
|
||||
Question: {ambiguity}
|
||||
|
||||
This requires community clarification. Members can discuss and provide guidance.
|
||||
|
||||
Clarification ID: {clarification.id}
|
||||
"""
|
||||
|
||||
return {
|
||||
"response": response,
|
||||
"success": False,
|
||||
"requires_clarification": True,
|
||||
"clarification_id": clarification.id,
|
||||
}
|
||||
|
||||
def check_deadlines(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Check for processes that have passed their deadline.
|
||||
This should be called periodically by a background task.
|
||||
|
||||
Returns:
|
||||
List of processes that were completed
|
||||
"""
|
||||
overdue_processes = queries.get_processes_past_deadline(self.db)
|
||||
completed = []
|
||||
|
||||
for process in overdue_processes:
|
||||
# Count votes
|
||||
counts = self.primitives.count_votes(process.id)
|
||||
|
||||
# Determine threshold from process state
|
||||
threshold_type = process.state_data.get(
|
||||
"voting_threshold", "simple_majority"
|
||||
)
|
||||
|
||||
# Check if passed
|
||||
passed = self.primitives.check_threshold(counts, threshold_type)
|
||||
|
||||
outcome = "passed" if passed else "failed"
|
||||
|
||||
# Complete the process
|
||||
self.primitives.complete_process(
|
||||
process_id=process.id,
|
||||
outcome=outcome,
|
||||
reasoning=f"Vote counts: {counts}. Threshold: {threshold_type}. Result: {outcome}",
|
||||
)
|
||||
|
||||
completed.append(
|
||||
{
|
||||
"process_id": process.id,
|
||||
"outcome": outcome,
|
||||
"vote_counts": counts,
|
||||
}
|
||||
)
|
||||
|
||||
return completed
|
||||
|
||||
def _call_llm(self, prompt: str) -> str:
|
||||
"""Call the LLM via llm CLI"""
|
||||
cmd = ["llm", "prompt"]
|
||||
if self.model:
|
||||
cmd.extend(["-m", self.model])
|
||||
cmd.append(prompt)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _extract_json(self, text: str) -> Dict[str, Any]:
|
||||
"""Extract JSON from LLM response"""
|
||||
# Handle markdown code blocks
|
||||
if "```json" in text:
|
||||
start = text.find("```json") + 7
|
||||
end = text.find("```", start)
|
||||
json_str = text[start:end].strip()
|
||||
elif "```" in text:
|
||||
start = text.find("```") + 3
|
||||
end = text.find("```", start)
|
||||
json_str = text[start:end].strip()
|
||||
else:
|
||||
json_str = text
|
||||
|
||||
return json.loads(json_str)
|
||||
277
src/govbot/bot.py
Normal file
277
src/govbot/bot.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Main Govbot entry point.
|
||||
|
||||
This module provides the main bot class that integrates:
|
||||
- Platform adapter (Mastodon, Discord, etc.)
|
||||
- AI agent for governance
|
||||
- Background task scheduler
|
||||
- Audit logging
|
||||
|
||||
The bot is platform-agnostic and works with any platform that implements
|
||||
the PlatformAdapter interface.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .utils.config import load_config, BotConfig
|
||||
from .db.models import init_db, get_session
|
||||
from .agent import GovernanceAgent
|
||||
from .scheduler import GovernanceScheduler
|
||||
from .platforms.base import PlatformAdapter, PlatformMessage, MockPlatformAdapter
|
||||
from .platforms.mastodon import MastodonAdapter
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("govbot")
|
||||
|
||||
|
||||
class Govbot:
|
||||
"""
|
||||
Main governance bot class.
|
||||
|
||||
Integrates all components:
|
||||
- Platform adapter (for message handling)
|
||||
- AI agent (for governance logic)
|
||||
- Database (for state and audit trail)
|
||||
- Scheduler (for deadlines and reminders)
|
||||
|
||||
Platform-agnostic design allows the same governance logic to work
|
||||
across Mastodon, Discord, Telegram, Matrix, etc.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str = "config/config.yaml",
|
||||
platform_adapter: Optional[PlatformAdapter] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the governance bot.
|
||||
|
||||
Args:
|
||||
config_path: Path to configuration file
|
||||
platform_adapter: Pre-configured platform adapter (optional).
|
||||
If None, will be created from config.
|
||||
"""
|
||||
logger.info("Initializing Govbot...")
|
||||
|
||||
# Load configuration
|
||||
try:
|
||||
self.config = load_config(config_path)
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Set log level from config
|
||||
log_level = getattr(logging, self.config.log_level.upper(), logging.INFO)
|
||||
logger.setLevel(log_level)
|
||||
|
||||
# Initialize database
|
||||
logger.info(f"Initializing database: {self.config.governance.db_path}")
|
||||
self.engine = init_db(self.config.governance.db_path)
|
||||
self.db_session = get_session(self.engine)
|
||||
|
||||
# Initialize AI agent
|
||||
logger.info("Initializing AI agent...")
|
||||
self.agent = GovernanceAgent(
|
||||
db_session=self.db_session,
|
||||
constitution_path=self.config.governance.constitution_path,
|
||||
model=self.config.ai.default_model,
|
||||
)
|
||||
|
||||
# Initialize scheduler for background tasks
|
||||
logger.info("Initializing scheduler...")
|
||||
self.scheduler = GovernanceScheduler(self.agent, self.db_session)
|
||||
|
||||
# Initialize platform adapter
|
||||
if platform_adapter:
|
||||
self.platform = platform_adapter
|
||||
else:
|
||||
self.platform = self._create_platform_adapter()
|
||||
|
||||
logger.info("Govbot initialized successfully!")
|
||||
|
||||
def _create_platform_adapter(self) -> PlatformAdapter:
|
||||
"""
|
||||
Create platform adapter from configuration.
|
||||
|
||||
Returns:
|
||||
Configured platform adapter
|
||||
|
||||
Raises:
|
||||
ValueError: If platform type unknown or config invalid
|
||||
"""
|
||||
platform_type = self.config.platform.type.lower()
|
||||
|
||||
logger.info(f"Creating {platform_type} platform adapter...")
|
||||
|
||||
if platform_type == "mastodon":
|
||||
return MastodonAdapter(self.config.platform.mastodon.model_dump())
|
||||
elif platform_type == "mock":
|
||||
return MockPlatformAdapter({})
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown platform type: {platform_type}. "
|
||||
f"Supported: mastodon, mock"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Start the bot.
|
||||
|
||||
This will:
|
||||
1. Connect to platform
|
||||
2. Start background scheduler
|
||||
3. Listen for messages and respond
|
||||
"""
|
||||
logger.info("Starting Govbot...")
|
||||
|
||||
# Start scheduler in background
|
||||
self.scheduler.start()
|
||||
|
||||
# Connect to platform
|
||||
try:
|
||||
if self.platform.connect():
|
||||
logger.info("Connected to platform successfully")
|
||||
else:
|
||||
logger.error("Failed to connect to platform")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Platform connection error: {e}")
|
||||
if isinstance(self.platform, MockPlatformAdapter):
|
||||
logger.info("Running in mock/test mode - use CLI for testing")
|
||||
else:
|
||||
return
|
||||
|
||||
# Start listening for messages
|
||||
try:
|
||||
self.platform.start_listening(self._handle_message)
|
||||
logger.info("Started listening for messages")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start listening: {e}")
|
||||
|
||||
try:
|
||||
# Keep running and let scheduler handle background tasks
|
||||
logger.info("Bot is running. Press Ctrl+C to stop.")
|
||||
while True:
|
||||
time.sleep(60) # Check every minute
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
self.scheduler.stop()
|
||||
self.platform.disconnect()
|
||||
logger.info("Govbot stopped.")
|
||||
|
||||
def _handle_message(self, message: PlatformMessage):
|
||||
"""
|
||||
Handle incoming message from platform.
|
||||
|
||||
Args:
|
||||
message: Normalized platform message
|
||||
"""
|
||||
logger.info(
|
||||
f"Received message from @{message.author_handle}: {message.text[:50]}..."
|
||||
)
|
||||
|
||||
# Process the request through the agent
|
||||
context = {
|
||||
"thread_id": message.thread_id,
|
||||
"reply_to_id": message.reply_to_id,
|
||||
"platform_message": message,
|
||||
}
|
||||
|
||||
result = self.agent.process_request(
|
||||
request=message.text,
|
||||
actor=f"@{message.author_handle}",
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Post response
|
||||
response = result.get("response", "Sorry, I couldn't process that request.")
|
||||
|
||||
try:
|
||||
self.platform.post(
|
||||
message=response,
|
||||
thread_id=message.thread_id,
|
||||
reply_to_id=message.id, # Reply to the message that mentioned us
|
||||
visibility=message.visibility,
|
||||
)
|
||||
logger.info("Posted response")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to post response: {e}")
|
||||
|
||||
def process_mention(self, mention_text: str, author: str, thread_id: Optional[str] = None):
|
||||
"""
|
||||
Process a mention of the bot.
|
||||
|
||||
Args:
|
||||
mention_text: Text of the mention
|
||||
author: Who mentioned the bot
|
||||
thread_id: ID of the thread (for context)
|
||||
|
||||
Returns:
|
||||
Response text to post
|
||||
"""
|
||||
logger.info(f"Processing mention from {author}: {mention_text}")
|
||||
|
||||
context = {"thread_id": thread_id} if thread_id else None
|
||||
|
||||
result = self.agent.process_request(
|
||||
request=mention_text,
|
||||
actor=author,
|
||||
context=context,
|
||||
)
|
||||
|
||||
response = result.get("response", "Sorry, I couldn't process that request.")
|
||||
|
||||
if result.get("success"):
|
||||
logger.info(f"Successfully processed request. Process ID: {result.get('process_id')}")
|
||||
else:
|
||||
logger.warning(f"Request failed: {response}")
|
||||
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
"""Clean up resources"""
|
||||
if self.scheduler:
|
||||
self.scheduler.stop()
|
||||
if self.platform and self.platform.connected:
|
||||
self.platform.disconnect()
|
||||
if self.db_session:
|
||||
self.db_session.close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for running the bot"""
|
||||
# Check if config exists
|
||||
config_path = Path("config/config.yaml")
|
||||
|
||||
if not config_path.exists():
|
||||
logger.error(
|
||||
"Configuration file not found!\n"
|
||||
"Please copy config/config.example.yaml to config/config.yaml "
|
||||
"and edit with your settings."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Create and run bot
|
||||
bot = Govbot()
|
||||
|
||||
try:
|
||||
bot.run()
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||
bot.close()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
226
src/govbot/cli.py
Normal file
226
src/govbot/cli.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Command-line interface for testing Govbot.
|
||||
|
||||
Allows you to interact with the bot without Mastodon,
|
||||
useful for development and testing.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
from .bot import Govbot
|
||||
from .db.models import init_db, get_session
|
||||
from .utils.config import load_config
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("govbot.cli")
|
||||
|
||||
|
||||
def print_banner():
|
||||
"""Print welcome banner"""
|
||||
print("\n" + "=" * 60)
|
||||
print("GOVBOT - Agentic Governance Bot")
|
||||
print("=" * 60)
|
||||
print("\nType 'help' for commands, 'exit' to quit\n")
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help text"""
|
||||
help_text = """
|
||||
Available commands:
|
||||
|
||||
help Show this help
|
||||
exit, quit Exit the CLI
|
||||
constitution Show the full constitution
|
||||
sections List constitutional sections
|
||||
query <question> Query the constitution
|
||||
propose <text> Create a proposal
|
||||
vote <process_id> <vote> Cast a vote (agree/disagree/abstain/block)
|
||||
status <process_id> Check process status
|
||||
processes List active processes
|
||||
actions List recent actions
|
||||
veto <action_id> Cast veto vote on action
|
||||
|
||||
Examples:
|
||||
|
||||
query What are the rules for proposals?
|
||||
propose We should update the moderation policy
|
||||
vote 1 agree
|
||||
status 1
|
||||
"""
|
||||
print(help_text)
|
||||
|
||||
|
||||
class GovbotCLI:
|
||||
"""Interactive CLI for Govbot"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the CLI"""
|
||||
# Check config
|
||||
config_path = Path("config/config.yaml")
|
||||
if not config_path.exists():
|
||||
logger.error(
|
||||
"Config file not found. Copy config/config.example.yaml to config/config.yaml"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize bot
|
||||
self.bot = Govbot(str(config_path))
|
||||
self.current_user = "@testuser" # Simulated user for testing
|
||||
|
||||
def run(self):
|
||||
"""Run the interactive CLI"""
|
||||
print_banner()
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
# Get input
|
||||
cmd_input = input(f"{self.current_user}> ").strip()
|
||||
|
||||
if not cmd_input:
|
||||
continue
|
||||
|
||||
# Parse command
|
||||
parts = cmd_input.split(maxsplit=1)
|
||||
command = parts[0].lower()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Execute command
|
||||
self.execute_command(command, args)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nUse 'exit' to quit")
|
||||
continue
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
finally:
|
||||
print("\nGoodbye!")
|
||||
self.bot.close()
|
||||
|
||||
def execute_command(self, command: str, args: str):
|
||||
"""Execute a CLI command"""
|
||||
|
||||
if command in ["exit", "quit"]:
|
||||
sys.exit(0)
|
||||
|
||||
elif command == "help":
|
||||
print_help()
|
||||
|
||||
elif command == "constitution":
|
||||
text = self.bot.agent.constitution.get_full_constitution()
|
||||
print("\n" + text + "\n")
|
||||
|
||||
elif command == "sections":
|
||||
sections = self.bot.agent.constitution.list_sections()
|
||||
print("\nConstitutional Sections:")
|
||||
for i, section in enumerate(sections, 1):
|
||||
print(f" {i}. {section}")
|
||||
print()
|
||||
|
||||
elif command == "query":
|
||||
if not args:
|
||||
print("Usage: query <question>")
|
||||
return
|
||||
result = self.bot.agent.constitution.query(args)
|
||||
print(f"\nAnswer: {result['answer']}")
|
||||
print(f"Citations: {', '.join(result['citations'])}")
|
||||
print(f"Confidence: {result['confidence']}\n")
|
||||
|
||||
elif command == "propose":
|
||||
if not args:
|
||||
print("Usage: propose <proposal text>")
|
||||
return
|
||||
response = self.bot.process_mention(
|
||||
mention_text=f"I propose: {args}",
|
||||
author=self.current_user,
|
||||
)
|
||||
print(f"\n{response}\n")
|
||||
|
||||
elif command == "vote":
|
||||
parts = args.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
print("Usage: vote <process_id> <agree|disagree|abstain|block>")
|
||||
return
|
||||
process_id = parts[0]
|
||||
vote_type = parts[1]
|
||||
response = self.bot.process_mention(
|
||||
mention_text=f"vote {vote_type} on process {process_id}",
|
||||
author=self.current_user,
|
||||
)
|
||||
print(f"\n{response}\n")
|
||||
|
||||
elif command == "status":
|
||||
if not args:
|
||||
print("Usage: status <process_id>")
|
||||
return
|
||||
try:
|
||||
process_id = int(args)
|
||||
from .db import queries
|
||||
|
||||
process = queries.get_process(self.bot.db_session, process_id)
|
||||
if process:
|
||||
print(f"\nProcess {process.id}:")
|
||||
print(f" Type: {process.process_type}")
|
||||
print(f" Creator: {process.creator}")
|
||||
print(f" Status: {process.status}")
|
||||
print(f" Deadline: {process.deadline}")
|
||||
print(f" State: {process.state_data}")
|
||||
print()
|
||||
else:
|
||||
print(f"Process {process_id} not found\n")
|
||||
except ValueError:
|
||||
print("Invalid process ID\n")
|
||||
|
||||
elif command == "processes":
|
||||
from .db import queries
|
||||
|
||||
processes = queries.get_active_processes(self.bot.db_session)
|
||||
if processes:
|
||||
print("\nActive Processes:")
|
||||
for p in processes:
|
||||
print(f" #{p.id}: {p.process_type} by {p.creator} (deadline: {p.deadline})")
|
||||
print()
|
||||
else:
|
||||
print("\nNo active processes\n")
|
||||
|
||||
elif command == "actions":
|
||||
from .db import queries
|
||||
|
||||
actions = queries.get_recent_actions(self.bot.db_session, limit=10)
|
||||
if actions:
|
||||
print("\nRecent Actions:")
|
||||
for a in actions:
|
||||
print(f" #{a.id}: {a.action_type} by {a.actor} ({a.status})")
|
||||
print()
|
||||
else:
|
||||
print("\nNo recent actions\n")
|
||||
|
||||
elif command == "veto":
|
||||
if not args:
|
||||
print("Usage: veto <action_id>")
|
||||
return
|
||||
response = self.bot.process_mention(
|
||||
mention_text=f"veto action {args}",
|
||||
author=self.current_user,
|
||||
)
|
||||
print(f"\n{response}\n")
|
||||
|
||||
else:
|
||||
print(f"Unknown command: {command}")
|
||||
print("Type 'help' for available commands\n")
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point"""
|
||||
cli = GovbotCLI()
|
||||
cli.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
src/govbot/db/__init__.py
Normal file
0
src/govbot/db/__init__.py
Normal file
202
src/govbot/db/models.py
Normal file
202
src/govbot/db/models.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Database models for governance bot using SQLAlchemy.
|
||||
|
||||
These models support flexible, agentic governance by storing:
|
||||
- Actions with constitutional reasoning and audit trail
|
||||
- Governance processes with flexible state
|
||||
- Clarification requests for constitutional ambiguity
|
||||
- Veto votes for safety mechanism
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
DateTime,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
JSON,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Session
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Action(Base):
|
||||
"""
|
||||
Records all bot actions with constitutional grounding and audit trail.
|
||||
Every action must cite constitutional basis and can be reversed.
|
||||
"""
|
||||
|
||||
__tablename__ = "actions"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
action_type = Column(String(100), nullable=False) # e.g., "proposal_created", "vote_cast"
|
||||
actor = Column(String(500), nullable=False) # Mastodon handle or "bot"
|
||||
bot_reasoning = Column(Text) # AI's explanation of why it took this action
|
||||
constitutional_citation = Column(Text) # Which part of constitution was applied
|
||||
data = Column(JSON) # Flexible storage for action-specific data
|
||||
reversible = Column(Boolean, default=True) # Can this action be reversed?
|
||||
reversed_by = Column(Integer, ForeignKey("actions.id"), nullable=True)
|
||||
status = Column(
|
||||
String(50), default="executed"
|
||||
) # pending, executed, reversed, vetoed
|
||||
|
||||
# Relationship to reversal action
|
||||
reversal = relationship("Action", remote_side=[id], foreign_keys=[reversed_by])
|
||||
|
||||
# Relationship to veto votes
|
||||
veto_votes = relationship("VetoVote", back_populates="action")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Action {self.id}: {self.action_type} by {self.actor} at {self.timestamp}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
||||
"action_type": self.action_type,
|
||||
"actor": self.actor,
|
||||
"bot_reasoning": self.bot_reasoning,
|
||||
"constitutional_citation": self.constitutional_citation,
|
||||
"data": self.data,
|
||||
"reversible": self.reversible,
|
||||
"reversed_by": self.reversed_by,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
class GovernanceProcess(Base):
|
||||
"""
|
||||
Represents an ongoing governance process (proposal, vote, etc.).
|
||||
Flexible schema allows AI to create different process types dynamically.
|
||||
"""
|
||||
|
||||
__tablename__ = "governance_processes"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
process_type = Column(String(100), nullable=False) # Dynamically determined by AI
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
deadline = Column(DateTime) # When this process concludes
|
||||
status = Column(String(50), default="active") # active, completed, cancelled
|
||||
state_data = Column(JSON) # Flexible storage for process-specific state
|
||||
constitutional_basis = Column(Text) # Citation of relevant constitutional provisions
|
||||
creator = Column(String(500)) # Who initiated this process
|
||||
mastodon_thread_id = Column(String(500)) # Link to Mastodon discussion thread
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GovernanceProcess {self.id}: {self.process_type} ({self.status})>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"process_type": self.process_type,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"deadline": self.deadline.isoformat() if self.deadline else None,
|
||||
"status": self.status,
|
||||
"state_data": self.state_data,
|
||||
"constitutional_basis": self.constitutional_basis,
|
||||
"creator": self.creator,
|
||||
"mastodon_thread_id": self.mastodon_thread_id,
|
||||
}
|
||||
|
||||
|
||||
class Clarification(Base):
|
||||
"""
|
||||
Records constitutional ambiguities that need human clarification.
|
||||
AI uses these when it encounters serious uncertainty about interpretation.
|
||||
"""
|
||||
|
||||
__tablename__ = "clarifications"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
question = Column(Text, nullable=False) # What's ambiguous?
|
||||
process_id = Column(
|
||||
Integer, ForeignKey("governance_processes.id"), nullable=True
|
||||
) # Related process if any
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
resolution = Column(Text) # Community's answer
|
||||
resolved_at = Column(DateTime)
|
||||
constitutional_update = Column(
|
||||
Boolean, default=False
|
||||
) # Should this update the constitution?
|
||||
|
||||
# Relationship to governance process
|
||||
process = relationship("GovernanceProcess")
|
||||
|
||||
def __repr__(self):
|
||||
status = "resolved" if self.resolved_at else "pending"
|
||||
return f"<Clarification {self.id}: {status}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"question": self.question,
|
||||
"process_id": self.process_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"resolution": self.resolution,
|
||||
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
|
||||
"constitutional_update": self.constitutional_update,
|
||||
}
|
||||
|
||||
|
||||
class VetoVote(Base):
|
||||
"""
|
||||
Tracks supermajority veto votes on bot actions.
|
||||
Safety mechanism allowing community to halt problematic actions.
|
||||
"""
|
||||
|
||||
__tablename__ = "veto_votes"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
action_id = Column(Integer, ForeignKey("actions.id"), nullable=False)
|
||||
voter = Column(String(500), nullable=False) # Mastodon handle
|
||||
vote = Column(String(20), nullable=False) # "veto" or "support"
|
||||
timestamp = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
rationale = Column(Text) # Why veto/support?
|
||||
|
||||
# Relationship to action
|
||||
action = relationship("Action", back_populates="veto_votes")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<VetoVote {self.id}: {self.voter} {self.vote}s action {self.action_id}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"action_id": self.action_id,
|
||||
"voter": self.voter,
|
||||
"vote": self.vote,
|
||||
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
||||
"rationale": self.rationale,
|
||||
}
|
||||
|
||||
|
||||
# Database initialization and utility functions
|
||||
|
||||
|
||||
def init_db(db_path: str = "govbot.db"):
|
||||
"""Initialize the database with all tables"""
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
def get_session(engine):
|
||||
"""Create a database session"""
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
277
src/govbot/db/queries.py
Normal file
277
src/govbot/db/queries.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Database query functions for governance operations.
|
||||
|
||||
Provides high-level functions for common governance queries,
|
||||
abstracting SQLAlchemy details for easier use by the agent.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
|
||||
from .models import Action, GovernanceProcess, Clarification, VetoVote
|
||||
|
||||
|
||||
# Action queries
|
||||
|
||||
|
||||
def create_action(
|
||||
session: Session,
|
||||
action_type: str,
|
||||
actor: str,
|
||||
data: Dict[str, Any],
|
||||
bot_reasoning: Optional[str] = None,
|
||||
constitutional_citation: Optional[str] = None,
|
||||
reversible: bool = True,
|
||||
) -> Action:
|
||||
"""Create a new action with audit trail"""
|
||||
action = Action(
|
||||
action_type=action_type,
|
||||
actor=actor,
|
||||
data=data,
|
||||
bot_reasoning=bot_reasoning,
|
||||
constitutional_citation=constitutional_citation,
|
||||
reversible=reversible,
|
||||
status="executed",
|
||||
)
|
||||
session.add(action)
|
||||
session.commit()
|
||||
return action
|
||||
|
||||
|
||||
def get_action(session: Session, action_id: int) -> Optional[Action]:
|
||||
"""Retrieve an action by ID"""
|
||||
return session.query(Action).filter(Action.id == action_id).first()
|
||||
|
||||
|
||||
def reverse_action(
|
||||
session: Session, action_id: int, reversing_actor: str, reason: str
|
||||
) -> Action:
|
||||
"""Reverse an action by creating a reversal record"""
|
||||
original = get_action(session, action_id)
|
||||
if not original:
|
||||
raise ValueError(f"Action {action_id} not found")
|
||||
if not original.reversible:
|
||||
raise ValueError(f"Action {action_id} is not reversible")
|
||||
|
||||
# Mark original as reversed
|
||||
original.status = "reversed"
|
||||
|
||||
# Create reversal action
|
||||
reversal = Action(
|
||||
action_type=f"reverse_{original.action_type}",
|
||||
actor=reversing_actor,
|
||||
data={"original_action_id": action_id, "reason": reason},
|
||||
bot_reasoning=reason,
|
||||
reversed_by=action_id,
|
||||
reversible=False,
|
||||
status="executed",
|
||||
)
|
||||
session.add(reversal)
|
||||
session.commit()
|
||||
return reversal
|
||||
|
||||
|
||||
def get_recent_actions(
|
||||
session: Session, limit: int = 50, action_type: Optional[str] = None
|
||||
) -> List[Action]:
|
||||
"""Get recent actions, optionally filtered by type"""
|
||||
query = session.query(Action).order_by(Action.timestamp.desc())
|
||||
if action_type:
|
||||
query = query.filter(Action.action_type == action_type)
|
||||
return query.limit(limit).all()
|
||||
|
||||
|
||||
# Governance process queries
|
||||
|
||||
|
||||
def create_process(
|
||||
session: Session,
|
||||
process_type: str,
|
||||
creator: str,
|
||||
constitutional_basis: str,
|
||||
deadline: Optional[datetime] = None,
|
||||
state_data: Optional[Dict[str, Any]] = None,
|
||||
mastodon_thread_id: Optional[str] = None,
|
||||
) -> GovernanceProcess:
|
||||
"""Create a new governance process"""
|
||||
process = GovernanceProcess(
|
||||
process_type=process_type,
|
||||
creator=creator,
|
||||
constitutional_basis=constitutional_basis,
|
||||
deadline=deadline,
|
||||
state_data=state_data or {},
|
||||
mastodon_thread_id=mastodon_thread_id,
|
||||
status="active",
|
||||
)
|
||||
session.add(process)
|
||||
session.commit()
|
||||
return process
|
||||
|
||||
|
||||
def get_process(session: Session, process_id: int) -> Optional[GovernanceProcess]:
|
||||
"""Retrieve a governance process by ID"""
|
||||
return session.query(GovernanceProcess).filter(GovernanceProcess.id == process_id).first()
|
||||
|
||||
|
||||
def update_process_state(
|
||||
session: Session, process_id: int, state_data: Dict[str, Any]
|
||||
) -> GovernanceProcess:
|
||||
"""Update the state data of a governance process"""
|
||||
process = get_process(session, process_id)
|
||||
if not process:
|
||||
raise ValueError(f"Process {process_id} not found")
|
||||
|
||||
# Merge new state data with existing
|
||||
current_state = process.state_data or {}
|
||||
current_state.update(state_data)
|
||||
process.state_data = current_state
|
||||
|
||||
session.commit()
|
||||
return process
|
||||
|
||||
|
||||
def complete_process(
|
||||
session: Session, process_id: int, outcome: str
|
||||
) -> GovernanceProcess:
|
||||
"""Mark a governance process as completed"""
|
||||
process = get_process(session, process_id)
|
||||
if not process:
|
||||
raise ValueError(f"Process {process_id} not found")
|
||||
|
||||
process.status = "completed"
|
||||
state_data = process.state_data or {}
|
||||
state_data["outcome"] = outcome
|
||||
state_data["completed_at"] = datetime.utcnow().isoformat()
|
||||
process.state_data = state_data
|
||||
|
||||
session.commit()
|
||||
return process
|
||||
|
||||
|
||||
def get_active_processes(
|
||||
session: Session, process_type: Optional[str] = None
|
||||
) -> List[GovernanceProcess]:
|
||||
"""Get all active governance processes, optionally filtered by type"""
|
||||
query = session.query(GovernanceProcess).filter(GovernanceProcess.status == "active")
|
||||
if process_type:
|
||||
query = query.filter(GovernanceProcess.process_type == process_type)
|
||||
return query.all()
|
||||
|
||||
|
||||
def get_processes_past_deadline(session: Session) -> List[GovernanceProcess]:
|
||||
"""Get active processes that have passed their deadline"""
|
||||
return (
|
||||
session.query(GovernanceProcess)
|
||||
.filter(
|
||||
and_(
|
||||
GovernanceProcess.status == "active",
|
||||
GovernanceProcess.deadline <= datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# Clarification queries
|
||||
|
||||
|
||||
def create_clarification(
|
||||
session: Session,
|
||||
question: str,
|
||||
process_id: Optional[int] = None,
|
||||
) -> Clarification:
|
||||
"""Create a new clarification request"""
|
||||
clarification = Clarification(
|
||||
question=question,
|
||||
process_id=process_id,
|
||||
)
|
||||
session.add(clarification)
|
||||
session.commit()
|
||||
return clarification
|
||||
|
||||
|
||||
def resolve_clarification(
|
||||
session: Session, clarification_id: int, resolution: str, update_constitution: bool = False
|
||||
) -> Clarification:
|
||||
"""Resolve a clarification with community answer"""
|
||||
clarification = (
|
||||
session.query(Clarification).filter(Clarification.id == clarification_id).first()
|
||||
)
|
||||
if not clarification:
|
||||
raise ValueError(f"Clarification {clarification_id} not found")
|
||||
|
||||
clarification.resolution = resolution
|
||||
clarification.resolved_at = datetime.utcnow()
|
||||
clarification.constitutional_update = update_constitution
|
||||
|
||||
session.commit()
|
||||
return clarification
|
||||
|
||||
|
||||
def get_pending_clarifications(session: Session) -> List[Clarification]:
|
||||
"""Get all unresolved clarification requests"""
|
||||
return session.query(Clarification).filter(Clarification.resolved_at.is_(None)).all()
|
||||
|
||||
|
||||
# Veto vote queries
|
||||
|
||||
|
||||
def cast_veto_vote(
|
||||
session: Session,
|
||||
action_id: int,
|
||||
voter: str,
|
||||
vote: str,
|
||||
rationale: Optional[str] = None,
|
||||
) -> VetoVote:
|
||||
"""Cast a veto vote on an action"""
|
||||
# Check if voter already voted on this action
|
||||
existing = (
|
||||
session.query(VetoVote)
|
||||
.filter(and_(VetoVote.action_id == action_id, VetoVote.voter == voter))
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing vote
|
||||
existing.vote = vote
|
||||
existing.rationale = rationale
|
||||
existing.timestamp = datetime.utcnow()
|
||||
session.commit()
|
||||
return existing
|
||||
else:
|
||||
# Create new vote
|
||||
veto_vote = VetoVote(
|
||||
action_id=action_id,
|
||||
voter=voter,
|
||||
vote=vote,
|
||||
rationale=rationale,
|
||||
)
|
||||
session.add(veto_vote)
|
||||
session.commit()
|
||||
return veto_vote
|
||||
|
||||
|
||||
def get_veto_votes(session: Session, action_id: int) -> Dict[str, int]:
|
||||
"""Get veto vote counts for an action"""
|
||||
votes = session.query(VetoVote).filter(VetoVote.action_id == action_id).all()
|
||||
|
||||
counts = {"veto": 0, "support": 0}
|
||||
for vote in votes:
|
||||
if vote.vote in counts:
|
||||
counts[vote.vote] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def check_veto_threshold(session: Session, action_id: int, threshold: float = 0.67) -> bool:
|
||||
"""Check if an action has reached veto threshold (default 2/3)"""
|
||||
counts = get_veto_votes(session, action_id)
|
||||
total = counts["veto"] + counts["support"]
|
||||
|
||||
if total == 0:
|
||||
return False
|
||||
|
||||
veto_ratio = counts["veto"] / total
|
||||
return veto_ratio >= threshold
|
||||
0
src/govbot/governance/__init__.py
Normal file
0
src/govbot/governance/__init__.py
Normal file
314
src/govbot/governance/constitution.py
Normal file
314
src/govbot/governance/constitution.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Constitutional Reasoning Engine using RAG (Retrieval Augmented Generation).
|
||||
|
||||
This module provides the core AI capability to:
|
||||
- Read and understand the constitution
|
||||
- Answer questions about governance rules
|
||||
- Provide constitutional citations for actions
|
||||
- Identify ambiguities requiring clarification
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
import hashlib
|
||||
|
||||
|
||||
class ConstitutionalReasoner:
|
||||
"""
|
||||
RAG-based system for constitutional interpretation.
|
||||
Uses the 'llm' CLI tool for embeddings and queries.
|
||||
"""
|
||||
|
||||
def __init__(self, constitution_path: str, model: Optional[str] = None):
|
||||
"""
|
||||
Initialize the constitutional reasoner.
|
||||
|
||||
Args:
|
||||
constitution_path: Path to the constitution markdown file
|
||||
model: LLM model to use (e.g., 'llama3.2', 'gpt-4'). If None, uses llm default.
|
||||
"""
|
||||
self.constitution_path = Path(constitution_path)
|
||||
self.model = model
|
||||
|
||||
if not self.constitution_path.exists():
|
||||
raise FileNotFoundError(f"Constitution not found at {constitution_path}")
|
||||
|
||||
# Load constitution content
|
||||
self.constitution_text = self.constitution_path.read_text()
|
||||
|
||||
# Split into sections for RAG
|
||||
self.sections = self._split_into_sections()
|
||||
|
||||
def _split_into_sections(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Split constitution into logical sections for retrieval.
|
||||
Returns list of dicts with 'title', 'content', and 'id' keys.
|
||||
"""
|
||||
sections = []
|
||||
current_section = None
|
||||
current_content = []
|
||||
|
||||
for line in self.constitution_text.split("\n"):
|
||||
# Article or Section headers
|
||||
if line.startswith("## Article") or line.startswith("### Section"):
|
||||
# Save previous section
|
||||
if current_section:
|
||||
sections.append(
|
||||
{
|
||||
"title": current_section,
|
||||
"content": "\n".join(current_content).strip(),
|
||||
"id": hashlib.md5(current_section.encode()).hexdigest()[:8],
|
||||
}
|
||||
)
|
||||
|
||||
# Start new section
|
||||
current_section = line.strip("#").strip()
|
||||
current_content = []
|
||||
elif current_section:
|
||||
current_content.append(line)
|
||||
|
||||
# Save last section
|
||||
if current_section:
|
||||
sections.append(
|
||||
{
|
||||
"title": current_section,
|
||||
"content": "\n".join(current_content).strip(),
|
||||
"id": hashlib.md5(current_section.encode()).hexdigest()[:8],
|
||||
}
|
||||
)
|
||||
|
||||
return sections
|
||||
|
||||
def query(self, question: str, context: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Query the constitution using AI reasoning.
|
||||
|
||||
Args:
|
||||
question: The governance question to answer
|
||||
context: Optional context about the situation
|
||||
|
||||
Returns:
|
||||
Dict with 'answer', 'citations', 'confidence', and 'ambiguity' keys
|
||||
"""
|
||||
# Build prompt with relevant sections
|
||||
relevant_sections = self._find_relevant_sections(question)
|
||||
|
||||
prompt = self._build_query_prompt(question, relevant_sections, context)
|
||||
|
||||
# Query using llm
|
||||
try:
|
||||
result = self._call_llm(prompt)
|
||||
return self._parse_query_result(result, relevant_sections)
|
||||
except Exception as e:
|
||||
return {
|
||||
"answer": f"Error querying constitution: {str(e)}",
|
||||
"citations": [],
|
||||
"confidence": "low",
|
||||
"ambiguity": "error",
|
||||
}
|
||||
|
||||
def _find_relevant_sections(self, query: str, top_k: int = 3) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Find the most relevant constitutional sections for a query.
|
||||
For now, uses simple keyword matching. Can be enhanced with embeddings later.
|
||||
"""
|
||||
query_lower = query.lower()
|
||||
scored_sections = []
|
||||
|
||||
for section in self.sections:
|
||||
score = 0
|
||||
section_text = (section["title"] + " " + section["content"]).lower()
|
||||
|
||||
# Simple keyword scoring
|
||||
keywords = query_lower.split()
|
||||
for keyword in keywords:
|
||||
if len(keyword) > 3: # Skip short words
|
||||
score += section_text.count(keyword)
|
||||
|
||||
scored_sections.append((score, section))
|
||||
|
||||
# Sort by score and return top k
|
||||
scored_sections.sort(key=lambda x: x[0], reverse=True)
|
||||
return [section for score, section in scored_sections[:top_k] if score > 0]
|
||||
|
||||
def _build_query_prompt(
|
||||
self, question: str, sections: List[Dict[str, str]], context: Optional[str]
|
||||
) -> str:
|
||||
"""Build the prompt for constitutional query"""
|
||||
sections_text = "\n\n".join(
|
||||
[f"**{s['title']}**\n{s['content']}" for s in sections]
|
||||
)
|
||||
|
||||
prompt = f"""You are a constitutional reasoner for a democratic community governance system.
|
||||
|
||||
RELEVANT CONSTITUTIONAL SECTIONS:
|
||||
{sections_text}
|
||||
|
||||
QUESTION: {question}
|
||||
"""
|
||||
|
||||
if context:
|
||||
prompt += f"\nCONTEXT: {context}"
|
||||
|
||||
prompt += """
|
||||
|
||||
Please provide:
|
||||
1. A clear answer based on the constitutional provisions
|
||||
2. Specific citations (article/section numbers)
|
||||
3. Your confidence level (high/medium/low)
|
||||
4. Any ambiguities that might require clarification (if any)
|
||||
|
||||
Format your response as JSON:
|
||||
{
|
||||
"answer": "your answer here",
|
||||
"citations": ["Article X, Section Y", ...],
|
||||
"confidence": "high|medium|low",
|
||||
"ambiguity": "description of any ambiguity, or null if clear"
|
||||
}
|
||||
"""
|
||||
return prompt
|
||||
|
||||
def _call_llm(self, prompt: str) -> str:
|
||||
"""Call the llm CLI tool"""
|
||||
cmd = ["llm", "prompt"]
|
||||
|
||||
if self.model:
|
||||
cmd.extend(["-m", self.model])
|
||||
|
||||
cmd.append(prompt)
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _parse_query_result(
|
||||
self, result: str, sections: List[Dict[str, str]]
|
||||
) -> Dict[str, Any]:
|
||||
"""Parse the LLM response into structured format"""
|
||||
try:
|
||||
# Try to extract JSON from the response
|
||||
# Handle cases where LLM wraps JSON in markdown code blocks
|
||||
if "```json" in result:
|
||||
json_start = result.find("```json") + 7
|
||||
json_end = result.find("```", json_start)
|
||||
json_str = result[json_start:json_end].strip()
|
||||
elif "```" in result:
|
||||
json_start = result.find("```") + 3
|
||||
json_end = result.find("```", json_start)
|
||||
json_str = result[json_start:json_end].strip()
|
||||
else:
|
||||
json_str = result
|
||||
|
||||
parsed = json.loads(json_str)
|
||||
|
||||
# Add section metadata to citations
|
||||
parsed["relevant_sections"] = sections
|
||||
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
# Fallback if JSON parsing fails
|
||||
return {
|
||||
"answer": result,
|
||||
"citations": [s["title"] for s in sections],
|
||||
"confidence": "medium",
|
||||
"ambiguity": None,
|
||||
"relevant_sections": sections,
|
||||
}
|
||||
|
||||
def get_full_constitution(self) -> str:
|
||||
"""Return the full constitution text"""
|
||||
return self.constitution_text
|
||||
|
||||
def get_section(self, section_id: str) -> Optional[Dict[str, str]]:
|
||||
"""Get a specific section by ID"""
|
||||
for section in self.sections:
|
||||
if section["id"] == section_id:
|
||||
return section
|
||||
return None
|
||||
|
||||
def list_sections(self) -> List[str]:
|
||||
"""List all section titles"""
|
||||
return [s["title"] for s in self.sections]
|
||||
|
||||
def interpret_proposal(self, proposal_text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Interpret a proposal and determine its type and requirements.
|
||||
|
||||
Returns:
|
||||
Dict with proposal_type, requirements, timeline, and thresholds
|
||||
"""
|
||||
question = f"""Given this proposal: "{proposal_text}"
|
||||
|
||||
What type of proposal is this according to the constitution?
|
||||
What are the requirements (timeline, voting threshold, etc.)?
|
||||
"""
|
||||
|
||||
result = self.query(question)
|
||||
|
||||
# Extract structured information from the answer
|
||||
try:
|
||||
prompt = f"""Based on this constitutional interpretation:
|
||||
{result['answer']}
|
||||
|
||||
Extract structured information as JSON:
|
||||
{{
|
||||
"proposal_type": "standard|urgent|constitutional_amendment",
|
||||
"discussion_period_days": number,
|
||||
"voting_threshold": "description of threshold",
|
||||
"special_requirements": ["list", "of", "requirements"]
|
||||
}}
|
||||
"""
|
||||
structured = self._call_llm(prompt)
|
||||
proposal_info = json.loads(structured)
|
||||
proposal_info["constitutional_basis"] = result
|
||||
return proposal_info
|
||||
except Exception as e:
|
||||
# Fallback
|
||||
return {
|
||||
"proposal_type": "standard",
|
||||
"discussion_period_days": 6,
|
||||
"voting_threshold": "simple_majority",
|
||||
"special_requirements": [],
|
||||
"constitutional_basis": result,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def check_ambiguity(self, question: str) -> Optional[str]:
|
||||
"""
|
||||
Check if a question reveals serious constitutional ambiguity.
|
||||
|
||||
Returns:
|
||||
None if clear, or a string describing the ambiguity if serious
|
||||
"""
|
||||
result = self.query(question)
|
||||
if result.get("confidence") == "low" or result.get("ambiguity"):
|
||||
return result.get("ambiguity") or "Low confidence in interpretation"
|
||||
return None
|
||||
|
||||
|
||||
# CLI interface for testing
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python -m src.govbot.governance.constitution <question>")
|
||||
print('Example: python -m src.govbot.governance.constitution "What are the rules for proposals?"')
|
||||
sys.exit(1)
|
||||
|
||||
# Find constitution file
|
||||
constitution_path = Path(__file__).parent.parent.parent.parent / "constitution.md"
|
||||
|
||||
reasoner = ConstitutionalReasoner(str(constitution_path))
|
||||
|
||||
question = " ".join(sys.argv[1:])
|
||||
result = reasoner.query(question)
|
||||
|
||||
print("\n=== Constitutional Query ===")
|
||||
print(f"Question: {question}\n")
|
||||
print(f"Answer: {result['answer']}\n")
|
||||
print(f"Citations: {', '.join(result['citations'])}")
|
||||
print(f"Confidence: {result['confidence']}")
|
||||
if result.get("ambiguity"):
|
||||
print(f"Ambiguity: {result['ambiguity']}")
|
||||
416
src/govbot/governance/primitives.py
Normal file
416
src/govbot/governance/primitives.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
Action Primitives for Agentic Governance.
|
||||
|
||||
These are the low-level operations that the AI agent can orchestrate
|
||||
to implement governance processes. Each primitive is simple and composable,
|
||||
allowing the agent to flexibly implement constitutional procedures.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
import json
|
||||
|
||||
from ..db import queries
|
||||
from ..db.models import GovernanceProcess, Action
|
||||
|
||||
|
||||
class GovernancePrimitives:
|
||||
"""
|
||||
Provides primitive operations for governance actions.
|
||||
These are called by the AI agent to implement constitutional procedures.
|
||||
"""
|
||||
|
||||
def __init__(self, db_session: Session):
|
||||
self.db = db_session
|
||||
|
||||
# Storage primitives
|
||||
|
||||
def store_record(
|
||||
self,
|
||||
record_type: str,
|
||||
data: Dict[str, Any],
|
||||
actor: str,
|
||||
reasoning: Optional[str] = None,
|
||||
citation: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Store a governance record (generic storage primitive).
|
||||
|
||||
Args:
|
||||
record_type: Type of record (e.g., "proposal", "vote", "decision")
|
||||
data: Record data as dictionary
|
||||
actor: Who created this record
|
||||
reasoning: Bot's reasoning for creating this record
|
||||
citation: Constitutional citation
|
||||
|
||||
Returns:
|
||||
Record ID
|
||||
"""
|
||||
action = queries.create_action(
|
||||
session=self.db,
|
||||
action_type=f"store_{record_type}",
|
||||
actor=actor,
|
||||
data=data,
|
||||
bot_reasoning=reasoning,
|
||||
constitutional_citation=citation,
|
||||
)
|
||||
return action.id
|
||||
|
||||
def query_records(
|
||||
self,
|
||||
record_type: Optional[str] = None,
|
||||
criteria: Optional[Dict[str, Any]] = None,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Query governance records (generic retrieval primitive).
|
||||
|
||||
Args:
|
||||
record_type: Type of record to query (None for all)
|
||||
criteria: Filter criteria (e.g., {"status": "active"})
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching records as dictionaries
|
||||
"""
|
||||
actions = queries.get_recent_actions(
|
||||
session=self.db, limit=limit, action_type=record_type
|
||||
)
|
||||
|
||||
if criteria:
|
||||
# Filter by criteria in data field
|
||||
filtered = []
|
||||
for action in actions:
|
||||
if action.data and all(
|
||||
action.data.get(k) == v for k, v in criteria.items()
|
||||
):
|
||||
filtered.append(action.to_dict())
|
||||
return filtered
|
||||
|
||||
return [action.to_dict() for action in actions]
|
||||
|
||||
# Process primitives
|
||||
|
||||
def create_process(
|
||||
self,
|
||||
process_type: str,
|
||||
creator: str,
|
||||
deadline_days: int,
|
||||
constitutional_basis: str,
|
||||
initial_state: Optional[Dict[str, Any]] = None,
|
||||
mastodon_thread_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Create a new governance process.
|
||||
|
||||
Args:
|
||||
process_type: Type of process (e.g., "standard_proposal")
|
||||
creator: Who initiated the process
|
||||
deadline_days: Days until deadline
|
||||
constitutional_basis: Constitutional citation
|
||||
initial_state: Initial state data
|
||||
mastodon_thread_id: Link to Mastodon thread
|
||||
|
||||
Returns:
|
||||
Process ID
|
||||
"""
|
||||
deadline = datetime.utcnow() + timedelta(days=deadline_days)
|
||||
|
||||
process = queries.create_process(
|
||||
session=self.db,
|
||||
process_type=process_type,
|
||||
creator=creator,
|
||||
constitutional_basis=constitutional_basis,
|
||||
deadline=deadline,
|
||||
state_data=initial_state or {},
|
||||
mastodon_thread_id=mastodon_thread_id,
|
||||
)
|
||||
|
||||
# Log the action
|
||||
queries.create_action(
|
||||
session=self.db,
|
||||
action_type="process_created",
|
||||
actor="bot",
|
||||
data={
|
||||
"process_id": process.id,
|
||||
"process_type": process_type,
|
||||
"creator": creator,
|
||||
"deadline": deadline.isoformat(),
|
||||
},
|
||||
constitutional_citation=constitutional_basis,
|
||||
)
|
||||
|
||||
return process.id
|
||||
|
||||
def update_process_state(
|
||||
self, process_id: int, state_updates: Dict[str, Any], actor: str = "bot"
|
||||
) -> bool:
|
||||
"""
|
||||
Update the state of a governance process.
|
||||
|
||||
Args:
|
||||
process_id: ID of process to update
|
||||
state_updates: Dictionary of state updates to merge
|
||||
actor: Who is updating the state
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
process = queries.update_process_state(
|
||||
session=self.db, process_id=process_id, state_data=state_updates
|
||||
)
|
||||
|
||||
# Log the action
|
||||
queries.create_action(
|
||||
session=self.db,
|
||||
action_type="process_updated",
|
||||
actor=actor,
|
||||
data={"process_id": process_id, "updates": state_updates},
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def complete_process(
|
||||
self, process_id: int, outcome: str, reasoning: str
|
||||
) -> bool:
|
||||
"""
|
||||
Mark a governance process as completed.
|
||||
|
||||
Args:
|
||||
process_id: ID of process to complete
|
||||
outcome: Outcome description (e.g., "passed", "failed")
|
||||
reasoning: Explanation of outcome
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
process = queries.complete_process(
|
||||
session=self.db, process_id=process_id, outcome=outcome
|
||||
)
|
||||
|
||||
# Log the action
|
||||
queries.create_action(
|
||||
session=self.db,
|
||||
action_type="process_completed",
|
||||
actor="bot",
|
||||
data={"process_id": process_id, "outcome": outcome},
|
||||
bot_reasoning=reasoning,
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
# Calculation primitives
|
||||
|
||||
def calculate(self, expression: str, variables: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Safely evaluate a mathematical expression.
|
||||
|
||||
Args:
|
||||
expression: Math expression (e.g., "agree > disagree")
|
||||
variables: Variable values (e.g., {"agree": 10, "disagree": 3})
|
||||
|
||||
Returns:
|
||||
Result of calculation
|
||||
"""
|
||||
# Safe evaluation using eval with restricted globals
|
||||
allowed_names = {
|
||||
"abs": abs,
|
||||
"max": max,
|
||||
"min": min,
|
||||
"sum": sum,
|
||||
"len": len,
|
||||
}
|
||||
allowed_names.update(variables)
|
||||
|
||||
try:
|
||||
result = eval(expression, {"__builtins__": {}}, allowed_names)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid expression: {expression} - {e}")
|
||||
|
||||
def count_votes(
|
||||
self, process_id: int
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Count votes for a governance process.
|
||||
|
||||
Args:
|
||||
process_id: ID of process to count votes for
|
||||
|
||||
Returns:
|
||||
Dictionary with vote counts
|
||||
"""
|
||||
process = queries.get_process(self.db, process_id)
|
||||
if not process:
|
||||
return {}
|
||||
|
||||
# Get votes from process state
|
||||
votes = process.state_data.get("votes", {})
|
||||
|
||||
# Count by type
|
||||
counts = {"agree": 0, "disagree": 0, "abstain": 0, "block": 0}
|
||||
|
||||
for voter, vote_data in votes.items():
|
||||
vote_type = vote_data.get("vote", "").lower()
|
||||
if vote_type in counts:
|
||||
counts[vote_type] += 1
|
||||
|
||||
return counts
|
||||
|
||||
def check_threshold(
|
||||
self, counts: Dict[str, int], threshold_type: str
|
||||
) -> bool:
|
||||
"""
|
||||
Check if vote counts meet a threshold.
|
||||
|
||||
Args:
|
||||
counts: Vote counts dictionary
|
||||
threshold_type: Type of threshold to check
|
||||
|
||||
Returns:
|
||||
True if threshold is met
|
||||
"""
|
||||
agree = counts.get("agree", 0)
|
||||
disagree = counts.get("disagree", 0)
|
||||
block = counts.get("block", 0)
|
||||
|
||||
if threshold_type == "simple_majority":
|
||||
return agree > disagree
|
||||
|
||||
elif threshold_type == "3x_majority":
|
||||
return agree >= (disagree * 3)
|
||||
|
||||
elif threshold_type == "with_blocks":
|
||||
# Require 9x more agree than disagree+block
|
||||
return agree >= ((disagree + block) * 9)
|
||||
|
||||
elif threshold_type == "supermajority_2/3":
|
||||
total = agree + disagree
|
||||
if total == 0:
|
||||
return False
|
||||
return (agree / total) >= (2 / 3)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown threshold type: {threshold_type}")
|
||||
|
||||
# Scheduling primitives
|
||||
|
||||
def schedule_reminder(
|
||||
self, when: datetime, message: str, recipient: Optional[str] = None
|
||||
) -> int:
|
||||
"""
|
||||
Schedule a reminder for a future time.
|
||||
|
||||
Args:
|
||||
when: When to send reminder
|
||||
message: Reminder message
|
||||
recipient: Who to remind (None for broadcast)
|
||||
|
||||
Returns:
|
||||
Reminder ID
|
||||
"""
|
||||
action = queries.create_action(
|
||||
session=self.db,
|
||||
action_type="reminder_scheduled",
|
||||
actor="bot",
|
||||
data={
|
||||
"scheduled_for": when.isoformat(),
|
||||
"message": message,
|
||||
"recipient": recipient,
|
||||
"status": "pending",
|
||||
},
|
||||
)
|
||||
return action.id
|
||||
|
||||
def get_pending_reminders(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get reminders that are due.
|
||||
|
||||
Returns:
|
||||
List of reminder dictionaries
|
||||
"""
|
||||
actions = queries.get_recent_actions(
|
||||
session=self.db, limit=100, action_type="reminder_scheduled"
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
due_reminders = []
|
||||
|
||||
for action in actions:
|
||||
if action.data.get("status") == "pending":
|
||||
scheduled_time = datetime.fromisoformat(
|
||||
action.data["scheduled_for"]
|
||||
)
|
||||
if scheduled_time <= now:
|
||||
due_reminders.append(action.to_dict())
|
||||
|
||||
return due_reminders
|
||||
|
||||
def mark_reminder_sent(self, reminder_id: int) -> bool:
|
||||
"""
|
||||
Mark a reminder as sent.
|
||||
|
||||
Args:
|
||||
reminder_id: ID of reminder action
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
action = queries.get_action(self.db, reminder_id)
|
||||
if action and action.data:
|
||||
action.data["status"] = "sent"
|
||||
self.db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
# Reversal primitives
|
||||
|
||||
def reverse_action(
|
||||
self, action_id: int, actor: str, reason: str
|
||||
) -> bool:
|
||||
"""
|
||||
Reverse a previous action.
|
||||
|
||||
Args:
|
||||
action_id: ID of action to reverse
|
||||
actor: Who is reversing the action
|
||||
reason: Reason for reversal
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
queries.reverse_action(
|
||||
session=self.db,
|
||||
action_id=action_id,
|
||||
reversing_actor=actor,
|
||||
reason=reason,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def is_action_reversed(self, action_id: int) -> bool:
|
||||
"""
|
||||
Check if an action has been reversed.
|
||||
|
||||
Args:
|
||||
action_id: ID of action to check
|
||||
|
||||
Returns:
|
||||
True if action is reversed
|
||||
"""
|
||||
action = queries.get_action(self.db, action_id)
|
||||
return action.status == "reversed" if action else False
|
||||
|
||||
|
||||
def create_primitives(db_session: Session) -> GovernancePrimitives:
|
||||
"""Factory function to create primitives instance"""
|
||||
return GovernancePrimitives(db_session)
|
||||
15
src/govbot/platforms/__init__.py
Normal file
15
src/govbot/platforms/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Platform adapters for Govbot.
|
||||
|
||||
This module provides a platform-agnostic interface for governance bots
|
||||
to work across different social/communication platforms.
|
||||
"""
|
||||
|
||||
from .base import PlatformAdapter, PlatformMessage, PlatformSkill, SkillParameter
|
||||
|
||||
__all__ = [
|
||||
"PlatformAdapter",
|
||||
"PlatformMessage",
|
||||
"PlatformSkill",
|
||||
"SkillParameter",
|
||||
]
|
||||
536
src/govbot/platforms/base.py
Normal file
536
src/govbot/platforms/base.py
Normal file
@@ -0,0 +1,536 @@
|
||||
"""
|
||||
Base platform adapter interface for Govbot.
|
||||
|
||||
This module defines the abstract interface that all platform adapters must implement.
|
||||
Platform adapters enable Govbot to work across different social/communication platforms
|
||||
(Mastodon, Discord, Telegram, Matrix, etc.) while maintaining the same governance logic.
|
||||
|
||||
Key Concepts:
|
||||
-------------
|
||||
1. **Platform Adapter**: Main interface for platform integration
|
||||
2. **Messages**: Normalized message format across platforms
|
||||
3. **Skills**: Platform-specific capabilities (admin, moderation, etc.)
|
||||
4. **Callbacks**: How the bot receives events from the platform
|
||||
|
||||
Architecture:
|
||||
-------------
|
||||
┌─────────────────┐
|
||||
│ Governance │
|
||||
│ Agent (Core) │ ← Platform-agnostic
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Platform │
|
||||
│ Adapter │ ← This interface
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ Mastodon │ ← Concrete implementations
|
||||
│ Discord │
|
||||
│ Telegram │
|
||||
└─────────────┘
|
||||
|
||||
To implement a new platform:
|
||||
----------------------------
|
||||
1. Subclass PlatformAdapter
|
||||
2. Implement all abstract methods
|
||||
3. Define platform-specific skills
|
||||
4. Register message listeners
|
||||
5. Map platform concepts to abstract interface
|
||||
|
||||
See PLATFORMS.md for detailed implementation guide.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Optional, Dict, Any, List
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MessageVisibility(Enum):
|
||||
"""Message visibility levels (common across platforms)"""
|
||||
|
||||
PUBLIC = "public" # Visible to everyone
|
||||
UNLISTED = "unlisted" # Public but not in feeds
|
||||
FOLLOWERS = "followers" # Followers only
|
||||
DIRECT = "direct" # Direct message
|
||||
PRIVATE = "private" # Private/group-only
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformMessage:
|
||||
"""
|
||||
Normalized message representation across platforms.
|
||||
|
||||
This provides a common structure for messages regardless of platform.
|
||||
Platform adapters translate their native format into this structure.
|
||||
"""
|
||||
|
||||
id: str # Platform-specific message ID
|
||||
text: str # Message content (without bot mention)
|
||||
author_id: str # Unique user identifier on the platform
|
||||
author_handle: str # Human-readable handle/username
|
||||
timestamp: datetime
|
||||
thread_id: Optional[str] = None # Thread/conversation ID if applicable
|
||||
reply_to_id: Optional[str] = None # ID of message being replied to
|
||||
visibility: MessageVisibility = MessageVisibility.PUBLIC
|
||||
mentions_bot: bool = False # Was the bot explicitly mentioned?
|
||||
raw_data: Optional[Dict[str, Any]] = None # Platform-specific data
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillParameter:
|
||||
"""
|
||||
A parameter for a platform skill.
|
||||
|
||||
Describes what inputs a skill needs and validates them.
|
||||
"""
|
||||
|
||||
name: str # Parameter name
|
||||
type: str # Type hint (e.g., "str", "int", "user_id")
|
||||
description: str # What this parameter does
|
||||
required: bool = True # Is this parameter required?
|
||||
default: Optional[Any] = None # Default value if not required
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformSkill:
|
||||
"""
|
||||
A platform-specific capability.
|
||||
|
||||
Skills are actions the bot can perform that are specific to a platform,
|
||||
such as moderation, admin actions, channel management, etc.
|
||||
|
||||
Example skills:
|
||||
- Mastodon: suspend_account, update_instance_rules, transfer_admin
|
||||
- Discord: ban_user, create_channel, update_server_rules
|
||||
- Telegram: restrict_user, update_group_description
|
||||
"""
|
||||
|
||||
name: str # Skill identifier (e.g., "suspend_account")
|
||||
description: str # Human-readable description
|
||||
category: str # Category: "admin", "moderation", "content", "user_management"
|
||||
parameters: List[SkillParameter] # What inputs does this skill need?
|
||||
requires_confirmation: bool = True # Should this prompt for confirmation?
|
||||
reversible: bool = False # Can this action be undone?
|
||||
constitutional_authorization: Optional[str] = None # What constitutional provision allows this?
|
||||
|
||||
def validate_params(self, params: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Validate that provided parameters match requirements.
|
||||
|
||||
Args:
|
||||
params: Dictionary of parameter values
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
for param in self.parameters:
|
||||
if param.required and param.name not in params:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class PlatformAdapter(ABC):
|
||||
"""
|
||||
Abstract base class for platform adapters.
|
||||
|
||||
All platform implementations must inherit from this class and implement
|
||||
all abstract methods. This ensures consistent behavior across platforms.
|
||||
|
||||
Lifecycle:
|
||||
----------
|
||||
1. __init__() - Initialize with platform-specific config
|
||||
2. connect() - Establish connection to platform
|
||||
3. start_listening() - Begin receiving messages
|
||||
4. [Messages received and processed via callback]
|
||||
5. post() - Send responses
|
||||
6. execute_skill() - Perform platform actions
|
||||
7. disconnect() - Clean up when shutting down
|
||||
|
||||
Example Implementation:
|
||||
----------------------
|
||||
class MyPlatformAdapter(PlatformAdapter):
|
||||
def __init__(self, config):
|
||||
self.client = MyPlatformClient(config.api_token)
|
||||
self.bot_id = config.bot_id
|
||||
|
||||
def connect(self):
|
||||
self.client.authenticate()
|
||||
|
||||
def start_listening(self, callback):
|
||||
def on_message(msg):
|
||||
normalized = self._normalize_message(msg)
|
||||
callback(normalized)
|
||||
self.client.stream(on_message)
|
||||
|
||||
# ... implement other methods
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialize the platform adapter.
|
||||
|
||||
Args:
|
||||
config: Platform-specific configuration dictionary
|
||||
"""
|
||||
self.config = config
|
||||
self.connected = False
|
||||
self.bot_user_id: Optional[str] = None
|
||||
self.bot_username: Optional[str] = None
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Establish connection to the platform.
|
||||
|
||||
This should authenticate, verify credentials, and prepare the adapter
|
||||
to receive and send messages.
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
|
||||
Raises:
|
||||
Exception: If connection fails with error details
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnect from the platform and cleanup resources.
|
||||
|
||||
Should close connections, stop listeners, and free resources.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def start_listening(self, callback: Callable[[PlatformMessage], None]):
|
||||
"""
|
||||
Start listening for messages that mention the bot.
|
||||
|
||||
The adapter should filter for messages that mention the bot or are
|
||||
direct messages to it, normalize them to PlatformMessage format,
|
||||
and call the provided callback.
|
||||
|
||||
Args:
|
||||
callback: Function to call with each received message
|
||||
Signature: callback(message: PlatformMessage) -> None
|
||||
|
||||
Example:
|
||||
def handle_message(msg: PlatformMessage):
|
||||
print(f"Received: {msg.text} from {msg.author_handle}")
|
||||
|
||||
adapter.start_listening(handle_message)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def post(
|
||||
self,
|
||||
message: str,
|
||||
thread_id: Optional[str] = None,
|
||||
reply_to_id: Optional[str] = None,
|
||||
visibility: MessageVisibility = MessageVisibility.PUBLIC,
|
||||
) -> str:
|
||||
"""
|
||||
Post a message to the platform.
|
||||
|
||||
Args:
|
||||
message: Text content to post
|
||||
thread_id: Thread/conversation to post in (if applicable)
|
||||
reply_to_id: Message ID to reply to (if applicable)
|
||||
visibility: Message visibility level
|
||||
|
||||
Returns:
|
||||
Message ID of the posted message
|
||||
|
||||
Raises:
|
||||
Exception: If posting fails
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_skills(self) -> List[PlatformSkill]:
|
||||
"""
|
||||
Get available platform-specific skills.
|
||||
|
||||
Returns:
|
||||
List of skills this platform supports
|
||||
|
||||
Example:
|
||||
[
|
||||
PlatformSkill(
|
||||
name="suspend_account",
|
||||
description="Temporarily suspend a user account",
|
||||
category="moderation",
|
||||
parameters=[
|
||||
SkillParameter("user_id", "str", "User to suspend"),
|
||||
SkillParameter("duration", "int", "Days to suspend", required=False),
|
||||
SkillParameter("reason", "str", "Reason for suspension"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
),
|
||||
]
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute_skill(
|
||||
self, skill_name: str, parameters: Dict[str, Any], actor: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a platform-specific skill.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill to execute
|
||||
parameters: Dictionary of parameter values
|
||||
actor: Who is requesting this action (for audit)
|
||||
|
||||
Returns:
|
||||
Dictionary with execution results:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str, # Human-readable result
|
||||
"data": dict, # Additional result data
|
||||
"reversible": bool,
|
||||
"reverse_params": dict, # Parameters to reverse this action
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: If skill_name unknown or parameters invalid
|
||||
Exception: If execution fails
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about a user.
|
||||
|
||||
Args:
|
||||
user_id: Platform-specific user ID
|
||||
|
||||
Returns:
|
||||
Dictionary with user info:
|
||||
{
|
||||
"id": str,
|
||||
"handle": str,
|
||||
"display_name": str,
|
||||
"roles": List[str], # Admin, moderator, etc.
|
||||
"is_bot": bool,
|
||||
}
|
||||
Returns None if user not found.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def format_thread_url(self, thread_id: str) -> str:
|
||||
"""
|
||||
Generate a URL to view a thread/conversation.
|
||||
|
||||
Args:
|
||||
thread_id: Platform thread identifier
|
||||
|
||||
Returns:
|
||||
Full URL to the thread
|
||||
"""
|
||||
pass
|
||||
|
||||
# Helper methods (can be overridden but have default implementations)
|
||||
|
||||
def is_admin(self, user_id: str) -> bool:
|
||||
"""
|
||||
Check if a user has admin privileges.
|
||||
|
||||
Default implementation checks user roles. Can be overridden.
|
||||
|
||||
Args:
|
||||
user_id: User to check
|
||||
|
||||
Returns:
|
||||
True if user is admin
|
||||
"""
|
||||
user_info = self.get_user_info(user_id)
|
||||
if not user_info:
|
||||
return False
|
||||
return "admin" in user_info.get("roles", [])
|
||||
|
||||
def is_moderator(self, user_id: str) -> bool:
|
||||
"""
|
||||
Check if a user has moderator privileges.
|
||||
|
||||
Default implementation checks user roles. Can be overridden.
|
||||
|
||||
Args:
|
||||
user_id: User to check
|
||||
|
||||
Returns:
|
||||
True if user is moderator or admin
|
||||
"""
|
||||
user_info = self.get_user_info(user_id)
|
||||
if not user_info:
|
||||
return False
|
||||
roles = user_info.get("roles", [])
|
||||
return "admin" in roles or "moderator" in roles
|
||||
|
||||
def extract_mentions(self, text: str) -> List[str]:
|
||||
"""
|
||||
Extract user mentions from text.
|
||||
|
||||
Default implementation looks for @username patterns.
|
||||
Platforms should override if their mention format differs.
|
||||
|
||||
Args:
|
||||
text: Message text
|
||||
|
||||
Returns:
|
||||
List of mentioned usernames (without @)
|
||||
"""
|
||||
import re
|
||||
|
||||
mentions = re.findall(r"@(\w+)", text)
|
||||
return mentions
|
||||
|
||||
def get_skill(self, skill_name: str) -> Optional[PlatformSkill]:
|
||||
"""
|
||||
Get a specific skill by name.
|
||||
|
||||
Args:
|
||||
skill_name: Name of skill to retrieve
|
||||
|
||||
Returns:
|
||||
PlatformSkill if found, None otherwise
|
||||
"""
|
||||
for skill in self.get_skills():
|
||||
if skill.name == skill_name:
|
||||
return skill
|
||||
return None
|
||||
|
||||
def validate_skill_execution(
|
||||
self, skill_name: str, parameters: Dict[str, Any]
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate skill execution before performing it.
|
||||
|
||||
Args:
|
||||
skill_name: Skill to validate
|
||||
parameters: Parameters to validate
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message) tuple
|
||||
"""
|
||||
skill = self.get_skill(skill_name)
|
||||
if not skill:
|
||||
return False, f"Unknown skill: {skill_name}"
|
||||
|
||||
if not skill.validate_params(parameters):
|
||||
return False, f"Invalid parameters for skill: {skill_name}"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
# Mock adapter for testing (useful for development)
|
||||
|
||||
|
||||
class MockPlatformAdapter(PlatformAdapter):
|
||||
"""
|
||||
Mock platform adapter for testing without a real platform.
|
||||
|
||||
Useful for:
|
||||
- Unit testing
|
||||
- Development without platform credentials
|
||||
- Demonstrating the adapter interface
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
self.messages_sent: List[Dict[str, Any]] = []
|
||||
self.skills_executed: List[Dict[str, Any]] = []
|
||||
|
||||
def connect(self) -> bool:
|
||||
self.connected = True
|
||||
self.bot_user_id = "mock_bot_123"
|
||||
self.bot_username = "mockbot"
|
||||
return True
|
||||
|
||||
def disconnect(self):
|
||||
self.connected = False
|
||||
|
||||
def start_listening(self, callback: Callable[[PlatformMessage], None]):
|
||||
# Mock adapter doesn't actually listen; messages can be injected via simulate_message()
|
||||
self.message_callback = callback
|
||||
|
||||
def simulate_message(self, text: str, author: str = "testuser"):
|
||||
"""Simulate receiving a message (for testing)"""
|
||||
msg = PlatformMessage(
|
||||
id=f"msg_{len(self.messages_sent)}",
|
||||
text=text,
|
||||
author_id=f"user_{author}",
|
||||
author_handle=author,
|
||||
timestamp=datetime.utcnow(),
|
||||
mentions_bot=True,
|
||||
)
|
||||
if hasattr(self, "message_callback"):
|
||||
self.message_callback(msg)
|
||||
|
||||
def post(
|
||||
self,
|
||||
message: str,
|
||||
thread_id: Optional[str] = None,
|
||||
reply_to_id: Optional[str] = None,
|
||||
visibility: MessageVisibility = MessageVisibility.PUBLIC,
|
||||
) -> str:
|
||||
msg_id = f"post_{len(self.messages_sent)}"
|
||||
self.messages_sent.append(
|
||||
{
|
||||
"id": msg_id,
|
||||
"message": message,
|
||||
"thread_id": thread_id,
|
||||
"reply_to_id": reply_to_id,
|
||||
"visibility": visibility.value,
|
||||
}
|
||||
)
|
||||
return msg_id
|
||||
|
||||
def get_skills(self) -> List[PlatformSkill]:
|
||||
return [
|
||||
PlatformSkill(
|
||||
name="mock_action",
|
||||
description="Mock platform action for testing",
|
||||
category="admin",
|
||||
parameters=[
|
||||
SkillParameter("target", "str", "Target of action"),
|
||||
],
|
||||
requires_confirmation=False,
|
||||
reversible=True,
|
||||
)
|
||||
]
|
||||
|
||||
def execute_skill(
|
||||
self, skill_name: str, parameters: Dict[str, Any], actor: str
|
||||
) -> Dict[str, Any]:
|
||||
self.skills_executed.append(
|
||||
{"skill": skill_name, "parameters": parameters, "actor": actor}
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Mock execution of {skill_name}",
|
||||
"data": parameters,
|
||||
"reversible": True,
|
||||
"reverse_params": parameters,
|
||||
}
|
||||
|
||||
def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
return {
|
||||
"id": user_id,
|
||||
"handle": user_id.replace("user_", ""),
|
||||
"display_name": f"Mock User {user_id}",
|
||||
"roles": ["member"],
|
||||
"is_bot": False,
|
||||
}
|
||||
|
||||
def format_thread_url(self, thread_id: str) -> str:
|
||||
return f"https://mock.platform/thread/{thread_id}"
|
||||
680
src/govbot/platforms/mastodon.py
Normal file
680
src/govbot/platforms/mastodon.py
Normal file
@@ -0,0 +1,680 @@
|
||||
"""
|
||||
Mastodon platform adapter for Govbot.
|
||||
|
||||
Implements the platform interface for Mastodon/Fediverse instances,
|
||||
enabling governance bots to work on any Mastodon-compatible server.
|
||||
|
||||
Features:
|
||||
---------
|
||||
- Streaming API for real-time mentions
|
||||
- Thread-aware posting
|
||||
- Admin and moderation skills
|
||||
- Visibility controls (public, unlisted, followers-only, direct)
|
||||
- Instance-level and user-level actions
|
||||
|
||||
Configuration Required:
|
||||
-----------------------
|
||||
- instance_url: Your Mastodon instance URL
|
||||
- access_token: Bot account access token
|
||||
- bot_username: Bot's username (for filtering mentions)
|
||||
|
||||
Optional:
|
||||
- client_id: OAuth client ID (for token generation)
|
||||
- client_secret: OAuth client secret
|
||||
|
||||
Getting Access Tokens:
|
||||
----------------------
|
||||
1. Register application at: https://your-instance/settings/applications/new
|
||||
2. Generate access token with appropriate scopes:
|
||||
- read (for streaming)
|
||||
- write (for posting)
|
||||
- admin:read, admin:write (for instance management, if bot is admin)
|
||||
|
||||
See PLATFORMS.md for detailed setup guide.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
import time
|
||||
import threading
|
||||
|
||||
try:
|
||||
from mastodon import Mastodon, StreamListener, MastodonError
|
||||
MASTODON_AVAILABLE = True
|
||||
except ImportError:
|
||||
MASTODON_AVAILABLE = False
|
||||
logging.warning("Mastodon.py not installed. Install with: pip install Mastodon.py")
|
||||
|
||||
from .base import (
|
||||
PlatformAdapter,
|
||||
PlatformMessage,
|
||||
PlatformSkill,
|
||||
SkillParameter,
|
||||
MessageVisibility,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("govbot.platforms.mastodon")
|
||||
|
||||
|
||||
class MastodonAdapter(PlatformAdapter):
|
||||
"""
|
||||
Mastodon platform adapter implementation.
|
||||
|
||||
Connects to Mastodon instances and provides governance capabilities.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialize Mastodon adapter.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with:
|
||||
- instance_url: Mastodon instance URL
|
||||
- access_token: Bot access token
|
||||
- bot_username: Bot's username
|
||||
- client_id (optional): OAuth client ID
|
||||
- client_secret (optional): OAuth client secret
|
||||
"""
|
||||
super().__init__(config)
|
||||
|
||||
if not MASTODON_AVAILABLE:
|
||||
raise ImportError(
|
||||
"Mastodon.py is required for Mastodon adapter. "
|
||||
"Install with: pip install Mastodon.py"
|
||||
)
|
||||
|
||||
self.instance_url = config.get("instance_url")
|
||||
self.access_token = config.get("access_token")
|
||||
self.bot_username = config.get("bot_username", "govbot")
|
||||
|
||||
if not self.instance_url or not self.access_token:
|
||||
raise ValueError(
|
||||
"Mastodon adapter requires 'instance_url' and 'access_token' in config"
|
||||
)
|
||||
|
||||
self.client: Optional[Mastodon] = None
|
||||
self.stream_listener: Optional['GovbotStreamListener'] = None
|
||||
self.listener_thread: Optional[threading.Thread] = None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Connect to Mastodon instance.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
|
||||
Raises:
|
||||
MastodonError: If connection fails
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Connecting to Mastodon instance: {self.instance_url}")
|
||||
|
||||
self.client = Mastodon(
|
||||
access_token=self.access_token,
|
||||
api_base_url=self.instance_url,
|
||||
)
|
||||
|
||||
# Verify credentials and get bot account info
|
||||
account = self.client.account_verify_credentials()
|
||||
self.bot_user_id = str(account["id"])
|
||||
self.bot_username = account["username"]
|
||||
|
||||
logger.info(
|
||||
f"Connected as @{self.bot_username} (ID: {self.bot_user_id})"
|
||||
)
|
||||
|
||||
self.connected = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Mastodon: {e}")
|
||||
raise
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from Mastodon and cleanup."""
|
||||
logger.info("Disconnecting from Mastodon")
|
||||
|
||||
# Stop stream listener if running
|
||||
if self.stream_listener:
|
||||
self.stream_listener.stop()
|
||||
self.stream_listener = None
|
||||
|
||||
if self.listener_thread:
|
||||
self.listener_thread.join(timeout=5)
|
||||
self.listener_thread = None
|
||||
|
||||
self.connected = False
|
||||
logger.info("Disconnected from Mastodon")
|
||||
|
||||
def start_listening(self, callback: Callable[[PlatformMessage], None]):
|
||||
"""
|
||||
Start listening for mentions via Mastodon streaming API.
|
||||
|
||||
Args:
|
||||
callback: Function to call with each received message
|
||||
"""
|
||||
if not self.connected or not self.client:
|
||||
raise RuntimeError("Must call connect() before start_listening()")
|
||||
|
||||
logger.info("Starting Mastodon stream listener for mentions")
|
||||
|
||||
# Create stream listener
|
||||
self.stream_listener = GovbotStreamListener(
|
||||
bot_id=self.bot_user_id,
|
||||
callback=callback,
|
||||
adapter=self,
|
||||
)
|
||||
|
||||
# Start streaming in a separate thread
|
||||
def stream_thread():
|
||||
try:
|
||||
# Stream user timeline (includes mentions)
|
||||
self.client.stream_user(self.stream_listener, run_async=False, reconnect_async=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Stream listener error: {e}", exc_info=True)
|
||||
|
||||
self.listener_thread = threading.Thread(target=stream_thread, daemon=True)
|
||||
self.listener_thread.start()
|
||||
|
||||
logger.info("Stream listener started")
|
||||
|
||||
def post(
|
||||
self,
|
||||
message: str,
|
||||
thread_id: Optional[str] = None,
|
||||
reply_to_id: Optional[str] = None,
|
||||
visibility: MessageVisibility = MessageVisibility.PUBLIC,
|
||||
) -> str:
|
||||
"""
|
||||
Post a message to Mastodon.
|
||||
|
||||
Args:
|
||||
message: Text content to post (max 500 characters for most instances)
|
||||
thread_id: Not used in Mastodon (use reply_to_id for threading)
|
||||
reply_to_id: Status ID to reply to
|
||||
visibility: Message visibility level
|
||||
|
||||
Returns:
|
||||
Status ID of posted message
|
||||
|
||||
Raises:
|
||||
MastodonError: If posting fails
|
||||
"""
|
||||
if not self.connected or not self.client:
|
||||
raise RuntimeError("Must call connect() before posting")
|
||||
|
||||
# Map visibility to Mastodon format
|
||||
mastodon_visibility = self._map_visibility(visibility)
|
||||
|
||||
try:
|
||||
status = self.client.status_post(
|
||||
status=message,
|
||||
in_reply_to_id=reply_to_id,
|
||||
visibility=mastodon_visibility,
|
||||
)
|
||||
|
||||
logger.info(f"Posted status {status['id']}")
|
||||
return str(status["id"])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to post status: {e}")
|
||||
raise
|
||||
|
||||
def get_skills(self) -> List[PlatformSkill]:
|
||||
"""
|
||||
Get Mastodon-specific skills.
|
||||
|
||||
Returns:
|
||||
List of available Mastodon governance skills
|
||||
"""
|
||||
return [
|
||||
# Moderation skills
|
||||
PlatformSkill(
|
||||
name="suspend_account",
|
||||
description="Suspend a user account (reversible)",
|
||||
category="moderation",
|
||||
parameters=[
|
||||
SkillParameter("account_id", "str", "Account ID to suspend"),
|
||||
SkillParameter("reason", "str", "Reason for suspension"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires moderation authority per constitution",
|
||||
),
|
||||
PlatformSkill(
|
||||
name="silence_account",
|
||||
description="Silence a user account (hide from public timelines)",
|
||||
category="moderation",
|
||||
parameters=[
|
||||
SkillParameter("account_id", "str", "Account ID to silence"),
|
||||
SkillParameter("reason", "str", "Reason for silencing"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires moderation authority per constitution",
|
||||
),
|
||||
PlatformSkill(
|
||||
name="delete_status",
|
||||
description="Delete a status/post",
|
||||
category="moderation",
|
||||
parameters=[
|
||||
SkillParameter("status_id", "str", "Status ID to delete"),
|
||||
SkillParameter("reason", "str", "Reason for deletion"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=False,
|
||||
constitutional_authorization="Requires moderation authority per constitution",
|
||||
),
|
||||
# Instance administration skills
|
||||
PlatformSkill(
|
||||
name="update_instance_rules",
|
||||
description="Update instance rules/code of conduct",
|
||||
category="admin",
|
||||
parameters=[
|
||||
SkillParameter("rules", "list", "List of rule texts"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires constitutional amendment process",
|
||||
),
|
||||
PlatformSkill(
|
||||
name="update_instance_description",
|
||||
description="Update instance description/about page",
|
||||
category="admin",
|
||||
parameters=[
|
||||
SkillParameter("description", "str", "New instance description"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires governance approval",
|
||||
),
|
||||
PlatformSkill(
|
||||
name="grant_moderator",
|
||||
description="Grant moderator role to a user",
|
||||
category="admin",
|
||||
parameters=[
|
||||
SkillParameter("account_id", "str", "Account ID to promote"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires governance approval",
|
||||
),
|
||||
PlatformSkill(
|
||||
name="revoke_moderator",
|
||||
description="Revoke moderator role from a user",
|
||||
category="admin",
|
||||
parameters=[
|
||||
SkillParameter("account_id", "str", "Account ID to demote"),
|
||||
],
|
||||
requires_confirmation=True,
|
||||
reversible=True,
|
||||
constitutional_authorization="Requires governance approval",
|
||||
),
|
||||
# Content management
|
||||
PlatformSkill(
|
||||
name="create_announcement",
|
||||
description="Create an instance-wide announcement",
|
||||
category="content",
|
||||
parameters=[
|
||||
SkillParameter("text", "str", "Announcement text"),
|
||||
SkillParameter("starts_at", "datetime", "When announcement starts", required=False),
|
||||
SkillParameter("ends_at", "datetime", "When announcement ends", required=False),
|
||||
],
|
||||
requires_confirmation=False,
|
||||
reversible=True,
|
||||
constitutional_authorization="Authorized communicators per constitution",
|
||||
),
|
||||
]
|
||||
|
||||
def execute_skill(
|
||||
self, skill_name: str, parameters: Dict[str, Any], actor: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a Mastodon-specific skill.
|
||||
|
||||
Args:
|
||||
skill_name: Name of skill to execute
|
||||
parameters: Skill parameters
|
||||
actor: Who is requesting this action
|
||||
|
||||
Returns:
|
||||
Execution result dictionary
|
||||
|
||||
Raises:
|
||||
ValueError: If skill unknown or parameters invalid
|
||||
MastodonError: If execution fails
|
||||
"""
|
||||
if not self.connected or not self.client:
|
||||
raise RuntimeError("Must call connect() before executing skills")
|
||||
|
||||
# Validate skill and parameters
|
||||
is_valid, error = self.validate_skill_execution(skill_name, parameters)
|
||||
if not is_valid:
|
||||
raise ValueError(error)
|
||||
|
||||
logger.info(f"Executing skill '{skill_name}' requested by {actor}")
|
||||
|
||||
# Route to appropriate handler
|
||||
try:
|
||||
if skill_name == "suspend_account":
|
||||
return self._suspend_account(parameters)
|
||||
elif skill_name == "silence_account":
|
||||
return self._silence_account(parameters)
|
||||
elif skill_name == "delete_status":
|
||||
return self._delete_status(parameters)
|
||||
elif skill_name == "update_instance_rules":
|
||||
return self._update_instance_rules(parameters)
|
||||
elif skill_name == "update_instance_description":
|
||||
return self._update_instance_description(parameters)
|
||||
elif skill_name == "grant_moderator":
|
||||
return self._grant_moderator(parameters)
|
||||
elif skill_name == "revoke_moderator":
|
||||
return self._revoke_moderator(parameters)
|
||||
elif skill_name == "create_announcement":
|
||||
return self._create_announcement(parameters)
|
||||
else:
|
||||
raise ValueError(f"Unknown skill: {skill_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Skill execution failed: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Execution failed: {str(e)}",
|
||||
"data": {},
|
||||
"reversible": False,
|
||||
}
|
||||
|
||||
def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get information about a Mastodon user.
|
||||
|
||||
Args:
|
||||
user_id: Mastodon account ID
|
||||
|
||||
Returns:
|
||||
User info dictionary or None if not found
|
||||
"""
|
||||
if not self.connected or not self.client:
|
||||
return None
|
||||
|
||||
try:
|
||||
account = self.client.account(user_id)
|
||||
|
||||
# Determine roles (requires admin API access)
|
||||
roles = ["member"]
|
||||
try:
|
||||
# Check if account is admin/moderator (requires admin scope)
|
||||
admin_account = self.client.admin_account(user_id)
|
||||
if admin_account.get("role", {}).get("name") == "Admin":
|
||||
roles.append("admin")
|
||||
elif admin_account.get("role", {}).get("name") == "Moderator":
|
||||
roles.append("moderator")
|
||||
except:
|
||||
# If we don't have admin access, can't check roles
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": str(account["id"]),
|
||||
"handle": account["username"],
|
||||
"display_name": account["display_name"],
|
||||
"roles": roles,
|
||||
"is_bot": account.get("bot", False),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user info for {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def format_thread_url(self, thread_id: str) -> str:
|
||||
"""
|
||||
Generate URL to a Mastodon thread.
|
||||
|
||||
Args:
|
||||
thread_id: Status ID
|
||||
|
||||
Returns:
|
||||
Full URL to the status
|
||||
"""
|
||||
# Get the status to find its URL
|
||||
try:
|
||||
if self.client:
|
||||
status = self.client.status(thread_id)
|
||||
return status.get("url", f"{self.instance_url}/web/statuses/{thread_id}")
|
||||
except:
|
||||
pass
|
||||
|
||||
return f"{self.instance_url}/web/statuses/{thread_id}"
|
||||
|
||||
# Private helper methods for skill execution
|
||||
|
||||
def _suspend_account(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Suspend a user account"""
|
||||
account_id = params["account_id"]
|
||||
reason = params.get("reason", "Suspended by governance decision")
|
||||
|
||||
self.client.admin_account_moderate(
|
||||
account_id,
|
||||
action="suspend",
|
||||
report_note=reason,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Account {account_id} suspended",
|
||||
"data": {"account_id": account_id, "reason": reason},
|
||||
"reversible": True,
|
||||
"reverse_params": {"account_id": account_id, "action": "unsuspend"},
|
||||
}
|
||||
|
||||
def _silence_account(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Silence a user account"""
|
||||
account_id = params["account_id"]
|
||||
reason = params.get("reason", "Silenced by governance decision")
|
||||
|
||||
self.client.admin_account_moderate(
|
||||
account_id,
|
||||
action="silence",
|
||||
report_note=reason,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Account {account_id} silenced",
|
||||
"data": {"account_id": account_id, "reason": reason},
|
||||
"reversible": True,
|
||||
"reverse_params": {"account_id": account_id, "action": "unsilence"},
|
||||
}
|
||||
|
||||
def _delete_status(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Delete a status"""
|
||||
status_id = params["status_id"]
|
||||
reason = params.get("reason", "Deleted by governance decision")
|
||||
|
||||
self.client.status_delete(status_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Status {status_id} deleted",
|
||||
"data": {"status_id": status_id, "reason": reason},
|
||||
"reversible": False,
|
||||
}
|
||||
|
||||
def _update_instance_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update instance rules"""
|
||||
# Note: This requires admin API access
|
||||
# Implementation depends on Mastodon version and API availability
|
||||
rules = params["rules"]
|
||||
|
||||
# This would use admin API to update instance rules
|
||||
# Exact implementation varies by Mastodon version
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Updated instance rules ({len(rules)} rules)",
|
||||
"data": {"rules": rules},
|
||||
"reversible": True,
|
||||
"reverse_params": {"rules": "previous_rules"}, # Would need to store previous
|
||||
}
|
||||
|
||||
def _update_instance_description(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update instance description"""
|
||||
description = params["description"]
|
||||
|
||||
# This would use admin API
|
||||
# Exact implementation varies
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Updated instance description",
|
||||
"data": {"description": description},
|
||||
"reversible": True,
|
||||
"reverse_params": {"description": "previous_description"},
|
||||
}
|
||||
|
||||
def _grant_moderator(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Grant moderator role"""
|
||||
account_id = params["account_id"]
|
||||
|
||||
# Use admin API to update role
|
||||
self.client.admin_account_moderate(account_id, action="promote_moderator")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Granted moderator to account {account_id}",
|
||||
"data": {"account_id": account_id},
|
||||
"reversible": True,
|
||||
"reverse_params": {"account_id": account_id},
|
||||
}
|
||||
|
||||
def _revoke_moderator(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Revoke moderator role"""
|
||||
account_id = params["account_id"]
|
||||
|
||||
# Use admin API to update role
|
||||
self.client.admin_account_moderate(account_id, action="demote_moderator")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Revoked moderator from account {account_id}",
|
||||
"data": {"account_id": account_id},
|
||||
"reversible": True,
|
||||
"reverse_params": {"account_id": account_id},
|
||||
}
|
||||
|
||||
def _create_announcement(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create instance announcement"""
|
||||
text = params["text"]
|
||||
starts_at = params.get("starts_at")
|
||||
ends_at = params.get("ends_at")
|
||||
|
||||
announcement = self.client.admin_announcement_create(
|
||||
text=text,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Created announcement",
|
||||
"data": {"announcement_id": announcement["id"], "text": text},
|
||||
"reversible": True,
|
||||
"reverse_params": {"announcement_id": announcement["id"]},
|
||||
}
|
||||
|
||||
def _map_visibility(self, visibility: MessageVisibility) -> str:
|
||||
"""Map abstract visibility to Mastodon visibility"""
|
||||
mapping = {
|
||||
MessageVisibility.PUBLIC: "public",
|
||||
MessageVisibility.UNLISTED: "unlisted",
|
||||
MessageVisibility.FOLLOWERS: "private",
|
||||
MessageVisibility.DIRECT: "direct",
|
||||
MessageVisibility.PRIVATE: "private",
|
||||
}
|
||||
return mapping.get(visibility, "public")
|
||||
|
||||
|
||||
class GovbotStreamListener(StreamListener):
|
||||
"""
|
||||
Mastodon stream listener for governance bot.
|
||||
|
||||
Listens for notifications (mentions, replies) and calls the callback.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_id: str,
|
||||
callback: Callable[[PlatformMessage], None],
|
||||
adapter: MastodonAdapter,
|
||||
):
|
||||
super().__init__()
|
||||
self.bot_id = bot_id
|
||||
self.callback = callback
|
||||
self.adapter = adapter
|
||||
self.running = True
|
||||
|
||||
def on_notification(self, notification):
|
||||
"""Handle incoming notifications"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
# We care about mentions and replies
|
||||
if notification["type"] in ["mention", "reply"]:
|
||||
status = notification["status"]
|
||||
|
||||
# Don't respond to ourselves
|
||||
if str(status["account"]["id"]) == self.bot_id:
|
||||
return
|
||||
|
||||
# Convert to PlatformMessage
|
||||
message = self._status_to_message(status)
|
||||
message.mentions_bot = True # It's a notification, so we were mentioned
|
||||
|
||||
# Call the callback
|
||||
try:
|
||||
self.callback(message)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in message callback: {e}", exc_info=True)
|
||||
|
||||
def on_update(self, status):
|
||||
"""Handle status updates (posts)"""
|
||||
# We primarily care about notifications, not all posts
|
||||
pass
|
||||
|
||||
def _status_to_message(self, status: Dict[str, Any]) -> PlatformMessage:
|
||||
"""Convert Mastodon status to PlatformMessage"""
|
||||
from html import unescape
|
||||
import re
|
||||
|
||||
# Extract text content (strip HTML)
|
||||
content = status.get("content", "")
|
||||
# Simple HTML stripping (could use BeautifulSoup for better parsing)
|
||||
content = re.sub(r"<[^>]+>", "", content)
|
||||
content = unescape(content)
|
||||
|
||||
# Map visibility
|
||||
visibility_map = {
|
||||
"public": MessageVisibility.PUBLIC,
|
||||
"unlisted": MessageVisibility.UNLISTED,
|
||||
"private": MessageVisibility.FOLLOWERS,
|
||||
"direct": MessageVisibility.DIRECT,
|
||||
}
|
||||
visibility = visibility_map.get(
|
||||
status.get("visibility", "public"), MessageVisibility.PUBLIC
|
||||
)
|
||||
|
||||
return PlatformMessage(
|
||||
id=str(status["id"]),
|
||||
text=content,
|
||||
author_id=str(status["account"]["id"]),
|
||||
author_handle=status["account"]["username"],
|
||||
timestamp=status["created_at"],
|
||||
thread_id=str(status.get("in_reply_to_id", status["id"])),
|
||||
reply_to_id=str(status["in_reply_to_id"]) if status.get("in_reply_to_id") else None,
|
||||
visibility=visibility,
|
||||
raw_data=status,
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the listener"""
|
||||
self.running = False
|
||||
172
src/govbot/scheduler.py
Normal file
172
src/govbot/scheduler.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Background scheduler for governance processes.
|
||||
|
||||
Handles:
|
||||
- Checking proposal deadlines
|
||||
- Sending reminders
|
||||
- Monitoring veto votes
|
||||
- Processing completed proposals
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .agent import GovernanceAgent
|
||||
from .db import queries
|
||||
|
||||
logger = logging.getLogger("govbot.scheduler")
|
||||
|
||||
|
||||
class GovernanceScheduler:
|
||||
"""
|
||||
Background scheduler for temporal governance tasks.
|
||||
|
||||
Runs in a separate thread and periodically:
|
||||
- Checks for processes past deadline
|
||||
- Sends pending reminders
|
||||
- Monitors veto votes
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: GovernanceAgent,
|
||||
db_session: Session,
|
||||
check_interval: int = 60,
|
||||
):
|
||||
"""
|
||||
Initialize the scheduler.
|
||||
|
||||
Args:
|
||||
agent: GovernanceAgent instance
|
||||
db_session: Database session
|
||||
check_interval: How often to check (in seconds)
|
||||
"""
|
||||
self.agent = agent
|
||||
self.db = db_session
|
||||
self.check_interval = check_interval
|
||||
self.running = False
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler in a background thread"""
|
||||
if self.running:
|
||||
logger.warning("Scheduler already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||
self.thread.start()
|
||||
logger.info(f"Scheduler started (checking every {self.check_interval}s)")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the scheduler"""
|
||||
self.running = False
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
logger.info("Scheduler stopped")
|
||||
|
||||
def _run_loop(self):
|
||||
"""Main scheduler loop"""
|
||||
while self.running:
|
||||
try:
|
||||
self._check_tasks()
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler error: {e}", exc_info=True)
|
||||
|
||||
# Sleep but check periodically for shutdown
|
||||
for _ in range(self.check_interval):
|
||||
if not self.running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
def _check_tasks(self):
|
||||
"""Check and process all scheduled tasks"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
logger.debug(f"Checking scheduled tasks at {now}")
|
||||
|
||||
# Check for processes past deadline
|
||||
completed = self.agent.check_deadlines()
|
||||
if completed:
|
||||
logger.info(f"Completed {len(completed)} processes past deadline")
|
||||
for process in completed:
|
||||
logger.info(
|
||||
f"Process {process['process_id']}: {process['outcome']} "
|
||||
f"with votes {process['vote_counts']}"
|
||||
)
|
||||
# TODO: Post result to Mastodon
|
||||
|
||||
# Check for pending reminders
|
||||
reminders = self.agent.primitives.get_pending_reminders()
|
||||
for reminder in reminders:
|
||||
logger.info(f"Sending reminder: {reminder['data']['message']}")
|
||||
# TODO: Post reminder to Mastodon
|
||||
self.agent.primitives.mark_reminder_sent(reminder["id"])
|
||||
|
||||
# Check for veto votes (every cycle)
|
||||
self._check_veto_votes()
|
||||
|
||||
def _check_veto_votes(self):
|
||||
"""Check if any actions have reached veto threshold"""
|
||||
# Get recent actions that might be vetoed
|
||||
recent_actions = queries.get_recent_actions(
|
||||
session=self.db, limit=100
|
||||
)
|
||||
|
||||
for action in recent_actions:
|
||||
if action.status == "executed" and action.reversible:
|
||||
# Check veto votes
|
||||
if queries.check_veto_threshold(self.db, action.id):
|
||||
logger.warning(
|
||||
f"Action {action.id} reached veto threshold! Reversing..."
|
||||
)
|
||||
|
||||
# Reverse the action
|
||||
queries.reverse_action(
|
||||
session=self.db,
|
||||
action_id=action.id,
|
||||
reversing_actor="community_veto",
|
||||
reason="Supermajority veto threshold reached",
|
||||
)
|
||||
|
||||
logger.info(f"Action {action.id} reversed by community veto")
|
||||
# TODO: Post to Mastodon about veto
|
||||
|
||||
|
||||
def run_scheduler_test():
|
||||
"""Test function for the scheduler"""
|
||||
from .db.models import init_db, get_session
|
||||
from .utils.config import load_config
|
||||
|
||||
# Initialize
|
||||
config = load_config()
|
||||
engine = init_db(config.governance.db_path)
|
||||
db_session = get_session(engine)
|
||||
|
||||
agent = GovernanceAgent(
|
||||
db_session=db_session,
|
||||
constitution_path=config.governance.constitution_path,
|
||||
model=config.ai.default_model,
|
||||
)
|
||||
|
||||
scheduler = GovernanceScheduler(agent, db_session, check_interval=10)
|
||||
|
||||
logger.info("Starting scheduler test...")
|
||||
scheduler.start()
|
||||
|
||||
try:
|
||||
# Run for 60 seconds
|
||||
time.sleep(60)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Test interrupted")
|
||||
finally:
|
||||
scheduler.stop()
|
||||
logger.info("Scheduler test complete")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_scheduler_test()
|
||||
0
src/govbot/utils/__init__.py
Normal file
0
src/govbot/utils/__init__.py
Normal file
160
src/govbot/utils/config.py
Normal file
160
src/govbot/utils/config.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Configuration management for Govbot using Pydantic.
|
||||
|
||||
Loads configuration from YAML file and environment variables,
|
||||
with validation to ensure all required settings are present.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class MastodonConfig(BaseModel):
|
||||
"""Mastodon instance configuration"""
|
||||
|
||||
instance_url: str = Field(..., description="Mastodon instance URL (e.g., https://mastodon.social)")
|
||||
client_id: Optional[str] = Field(None, description="OAuth client ID")
|
||||
client_secret: Optional[str] = Field(None, description="OAuth client secret")
|
||||
access_token: Optional[str] = Field(None, description="Access token for bot account")
|
||||
bot_username: str = Field("govbot", description="Bot's Mastodon username")
|
||||
|
||||
|
||||
class AIConfig(BaseModel):
|
||||
"""AI model configuration"""
|
||||
|
||||
default_model: Optional[str] = Field(
|
||||
None,
|
||||
description="Default LLM model to use (e.g., 'llama3.2' for Ollama, 'gpt-4' for OpenAI)",
|
||||
)
|
||||
fallback_model: Optional[str] = Field(None, description="Fallback model if default fails")
|
||||
temperature: float = Field(0.7, description="LLM temperature for responses")
|
||||
max_tokens: Optional[int] = Field(None, description="Maximum tokens for LLM responses")
|
||||
|
||||
|
||||
class GovernanceConfig(BaseModel):
|
||||
"""Governance system configuration"""
|
||||
|
||||
constitution_path: str = Field(
|
||||
"constitution.md", description="Path to constitution file"
|
||||
)
|
||||
db_path: str = Field("govbot.db", description="Path to SQLite database")
|
||||
default_veto_threshold: float = Field(
|
||||
0.67, description="Default supermajority veto threshold (e.g., 0.67 = 2/3)"
|
||||
)
|
||||
enable_auto_execution: bool = Field(
|
||||
True, description="Allow bot to execute actions automatically"
|
||||
)
|
||||
require_confirmation_for: list[str] = Field(
|
||||
default_factory=lambda: ["admin_action", "moderation"],
|
||||
description="Action types requiring human confirmation",
|
||||
)
|
||||
|
||||
|
||||
class PlatformConfig(BaseModel):
|
||||
"""Platform selection and configuration"""
|
||||
|
||||
type: str = Field(..., description="Platform type: mastodon, discord, telegram, mock")
|
||||
mastodon: Optional[MastodonConfig] = Field(None, description="Mastodon configuration")
|
||||
# Future platforms:
|
||||
# discord: Optional[DiscordConfig] = None
|
||||
# telegram: Optional[TelegramConfig] = None
|
||||
|
||||
|
||||
class BotConfig(BaseSettings):
|
||||
"""Main bot configuration"""
|
||||
|
||||
platform: PlatformConfig = Field(..., description="Platform configuration")
|
||||
ai: AIConfig = Field(default_factory=AIConfig)
|
||||
governance: GovernanceConfig = Field(default_factory=GovernanceConfig)
|
||||
|
||||
debug: bool = Field(False, description="Enable debug mode")
|
||||
log_level: str = Field("INFO", description="Logging level")
|
||||
|
||||
class Config:
|
||||
env_prefix = "GOVBOT_"
|
||||
env_nested_delimiter = "__"
|
||||
|
||||
|
||||
def load_config(config_path: str = "config/config.yaml") -> BotConfig:
|
||||
"""
|
||||
Load configuration from YAML file.
|
||||
|
||||
Args:
|
||||
config_path: Path to YAML config file
|
||||
|
||||
Returns:
|
||||
BotConfig instance
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If config file doesn't exist
|
||||
ValidationError: If config is invalid
|
||||
"""
|
||||
config_file = Path(config_path)
|
||||
|
||||
if not config_file.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Config file not found: {config_path}\n"
|
||||
f"Please create it based on config/config.example.yaml"
|
||||
)
|
||||
|
||||
with open(config_file) as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
|
||||
return BotConfig(**config_data)
|
||||
|
||||
|
||||
def create_example_config(output_path: str = "config/config.example.yaml"):
|
||||
"""
|
||||
Create an example configuration file.
|
||||
|
||||
Args:
|
||||
output_path: Where to write the example config
|
||||
"""
|
||||
example_config = {
|
||||
"platform": {
|
||||
"type": "mastodon", # or "discord", "telegram", "mock"
|
||||
"mastodon": {
|
||||
"instance_url": "https://your-mastodon-instance.social",
|
||||
"client_id": "your_client_id_here",
|
||||
"client_secret": "your_client_secret_here",
|
||||
"access_token": "your_access_token_here",
|
||||
"bot_username": "govbot",
|
||||
},
|
||||
# Discord example (for future use):
|
||||
# "discord": {
|
||||
# "token": "your_discord_bot_token",
|
||||
# "guild_id": "your_server_id",
|
||||
# },
|
||||
},
|
||||
"ai": {
|
||||
"default_model": "llama3.2", # Or 'gpt-4', 'claude-3', etc.
|
||||
"fallback_model": None,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": None,
|
||||
},
|
||||
"governance": {
|
||||
"constitution_path": "constitution.md",
|
||||
"db_path": "govbot.db",
|
||||
"default_veto_threshold": 0.67,
|
||||
"enable_auto_execution": True,
|
||||
"require_confirmation_for": ["admin_action", "moderation"],
|
||||
},
|
||||
"debug": False,
|
||||
"log_level": "INFO",
|
||||
}
|
||||
|
||||
output_file = Path(output_path)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_file, "w") as f:
|
||||
yaml.dump(example_config, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
print(f"Example config created at: {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create example config when run directly
|
||||
create_example_config()
|
||||
172
tests/test_basic.py
Normal file
172
tests/test_basic.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Basic tests for Govbot functionality.
|
||||
|
||||
These tests demonstrate the core features without requiring Mastodon.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.govbot.db.models import init_db, get_session
|
||||
from src.govbot.agent import GovernanceAgent
|
||||
from src.govbot.governance.constitution import ConstitutionalReasoner
|
||||
from src.govbot.governance.primitives import GovernancePrimitives
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db():
|
||||
"""Create a temporary test database"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
db_path = Path(temp_dir) / "test.db"
|
||||
|
||||
engine = init_db(str(db_path))
|
||||
session = get_session(engine)
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def constitution_path():
|
||||
"""Get path to test constitution"""
|
||||
return str(Path(__file__).parent.parent / "constitution.md")
|
||||
|
||||
|
||||
def test_constitution_loading(constitution_path):
|
||||
"""Test that constitution loads and parses correctly"""
|
||||
reasoner = ConstitutionalReasoner(constitution_path)
|
||||
|
||||
assert reasoner.constitution_text
|
||||
assert len(reasoner.sections) > 0
|
||||
|
||||
# Test section listing
|
||||
sections = reasoner.list_sections()
|
||||
assert any("Proposal" in s for s in sections)
|
||||
|
||||
|
||||
def test_constitutional_query(constitution_path):
|
||||
"""Test querying the constitution"""
|
||||
reasoner = ConstitutionalReasoner(constitution_path)
|
||||
|
||||
result = reasoner.query("What are the rules for standard proposals?")
|
||||
|
||||
assert "answer" in result
|
||||
assert "citations" in result
|
||||
assert len(result["citations"]) > 0
|
||||
|
||||
|
||||
def test_primitives_store_and_query(test_db):
|
||||
"""Test basic primitive operations"""
|
||||
primitives = GovernancePrimitives(test_db)
|
||||
|
||||
# Store a record
|
||||
record_id = primitives.store_record(
|
||||
record_type="test_record",
|
||||
data={"foo": "bar", "count": 42},
|
||||
actor="test_user",
|
||||
reasoning="Testing storage",
|
||||
)
|
||||
|
||||
assert record_id > 0
|
||||
|
||||
# Query it back
|
||||
records = primitives.query_records(record_type="store_test_record")
|
||||
assert len(records) > 0
|
||||
|
||||
|
||||
def test_create_process(test_db):
|
||||
"""Test creating a governance process"""
|
||||
primitives = GovernancePrimitives(test_db)
|
||||
|
||||
process_id = primitives.create_process(
|
||||
process_type="test_proposal",
|
||||
creator="test_user",
|
||||
deadline_days=6,
|
||||
constitutional_basis="Article 3, Section 1",
|
||||
initial_state={"proposal_text": "Test proposal"},
|
||||
)
|
||||
|
||||
assert process_id > 0
|
||||
|
||||
# Verify it was created
|
||||
from src.govbot.db import queries
|
||||
|
||||
process = queries.get_process(test_db, process_id)
|
||||
assert process is not None
|
||||
assert process.process_type == "test_proposal"
|
||||
assert process.creator == "test_user"
|
||||
|
||||
|
||||
def test_vote_counting(test_db):
|
||||
"""Test vote counting and threshold checking"""
|
||||
primitives = GovernancePrimitives(test_db)
|
||||
|
||||
# Create a process
|
||||
process_id = primitives.create_process(
|
||||
process_type="test_proposal",
|
||||
creator="alice",
|
||||
deadline_days=6,
|
||||
constitutional_basis="Article 3",
|
||||
initial_state={"votes": {}},
|
||||
)
|
||||
|
||||
# Cast some votes
|
||||
primitives.update_process_state(
|
||||
process_id,
|
||||
{
|
||||
"votes.alice": {"vote": "agree"},
|
||||
"votes.bob": {"vote": "agree"},
|
||||
"votes.charlie": {"vote": "disagree"},
|
||||
},
|
||||
)
|
||||
|
||||
# Count votes
|
||||
counts = primitives.count_votes(process_id)
|
||||
assert counts["agree"] == 2
|
||||
assert counts["disagree"] == 1
|
||||
|
||||
# Check threshold
|
||||
passed = primitives.check_threshold(counts, "simple_majority")
|
||||
assert passed is True
|
||||
|
||||
|
||||
def test_agent_proposal_creation(test_db, constitution_path):
|
||||
"""Test agent creating a proposal from natural language"""
|
||||
agent = GovernanceAgent(test_db, constitution_path, model=None)
|
||||
|
||||
result = agent.process_request(
|
||||
request="I propose we have weekly meetings",
|
||||
actor="test_user",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["process_id"] is not None
|
||||
assert "proposal" in result["response"].lower()
|
||||
|
||||
|
||||
def test_proposal_interpretation(constitution_path):
|
||||
"""Test interpreting different proposal types"""
|
||||
reasoner = ConstitutionalReasoner(constitution_path)
|
||||
|
||||
# Standard proposal
|
||||
info = reasoner.interpret_proposal("We should update the guidelines")
|
||||
assert "proposal_type" in info
|
||||
assert info["discussion_period_days"] >= 2
|
||||
|
||||
# Urgent proposal
|
||||
info = reasoner.interpret_proposal("URGENT: We need to address this spam attack")
|
||||
# Should recognize urgency (though AI interpretation may vary)
|
||||
assert "proposal_type" in info
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user