Files
agentic-govbot/src/govbot/memory.py
Nathan Schneider bda868cb45 Implement LLM-driven governance architecture with structured memory
This commit completes the transition to a pure LLM-driven agentic
governance system with no hard-coded governance logic.

Core Architecture Changes:
- Add structured memory system (memory.py) for tracking governance processes
- Add LLM tools (tools.py) for deterministic operations (math, dates, random)
- Add audit trail system (audit.py) for human-readable decision explanations
- Add LLM-driven agent (agent_refactored.py) that interprets constitution

Documentation:
- Add ARCHITECTURE.md describing process-centric design
- Add ARCHITECTURE_EXAMPLE.md with complete workflow walkthrough
- Update README.md to reflect current LLM-driven architecture
- Simplify constitution.md to benevolent dictator model for testing

Templates:
- Add 8 governance templates (petition, consensus, do-ocracy, jury, etc.)
- Add 8 dispute resolution templates
- All templates work with generic process-based architecture

Key Design Principles:
- "Process" is central abstraction (not "proposal")
- No hard-coded process types or thresholds
- LLM interprets constitution to understand governance rules
- Tools ensure correctness for calculations
- Complete auditability with reasoning and citations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 14:24:23 -07:00

477 lines
16 KiB
Python

"""
Structured Memory System for Agentic Governance.
This module provides a memory system that the LLM can query and update to track
governance state. The memory is structured, queryable, and human-readable for
auditability.
Key Design Goals:
1. LLM can query memory to understand current state
2. LLM can update memory with decisions and events
3. Memory is human-readable for audit
4. Memory preserves history and precedent
"""
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import Dict, Any, List, Optional
from enum import Enum
import json
class ProcessStatus(Enum):
"""Status of a governance process"""
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
SUSPENDED = "suspended"
@dataclass
class Event:
"""An event that occurred in a governance process"""
timestamp: datetime
actor: str # Who caused this event
event_type: str # "vote_cast", "proposal_submitted", "concern_raised", etc.
data: Dict[str, Any] # Event-specific data
context: str # Human-readable description
def to_dict(self) -> Dict[str, Any]:
return {
"timestamp": self.timestamp.isoformat(),
"actor": self.actor,
"event_type": self.event_type,
"data": self.data,
"context": self.context
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Event':
data = data.copy()
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
return cls(**data)
@dataclass
class Decision:
"""A decision made by the governance bot"""
timestamp: datetime
decision_type: str # "threshold_evaluation", "deadline_reached", etc.
reasoning: str # Natural language explanation
constitution_citations: List[str] # References to constitution sections
calculation_used: Optional[str] = None # Math expression if tool was used
calculation_variables: Optional[Dict[str, Any]] = None
calculation_result: Optional[Any] = None
result: Any = None # Decision outcome
precedent_references: List[str] = field(default_factory=list) # Related past decisions
def to_dict(self) -> Dict[str, Any]:
return {
"timestamp": self.timestamp.isoformat(),
"decision_type": self.decision_type,
"reasoning": self.reasoning,
"constitution_citations": self.constitution_citations,
"calculation_used": self.calculation_used,
"calculation_variables": self.calculation_variables,
"calculation_result": self.calculation_result,
"result": self.result,
"precedent_references": self.precedent_references
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Decision':
data = data.copy()
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
if 'precedent_references' not in data:
data['precedent_references'] = []
return cls(**data)
@dataclass
class ProcessMemory:
"""Memory of a governance process (proposal, dispute, election, etc.)"""
id: str # Unique identifier
type: str # "proposal", "dispute_resolution", "election", etc.
status: ProcessStatus
created_at: datetime
created_by: str
deadline: Optional[datetime] = None
constitution_basis: List[str] = field(default_factory=list) # Article/section citations
state: Dict[str, Any] = field(default_factory=dict) # Flexible process-specific state
events: List[Event] = field(default_factory=list) # Timeline of events
decisions: List[Decision] = field(default_factory=list) # Bot decisions
metadata: Dict[str, Any] = field(default_factory=dict) # Platform-specific data (thread IDs, etc.)
def add_event(self, event: Event):
"""Add an event to the process timeline"""
self.events.append(event)
def add_decision(self, decision: Decision):
"""Add a decision to the process"""
self.decisions.append(decision)
def update_state(self, updates: Dict[str, Any]):
"""Update process state"""
self.state.update(updates)
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"type": self.type,
"status": self.status.value,
"created_at": self.created_at.isoformat(),
"created_by": self.created_by,
"deadline": self.deadline.isoformat() if self.deadline else None,
"constitution_basis": self.constitution_basis,
"state": self.state,
"events": [e.to_dict() for e in self.events],
"decisions": [d.to_dict() for d in self.decisions],
"metadata": self.metadata
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ProcessMemory':
data = data.copy()
data['status'] = ProcessStatus(data['status'])
data['created_at'] = datetime.fromisoformat(data['created_at'])
if data.get('deadline'):
data['deadline'] = datetime.fromisoformat(data['deadline'])
data['events'] = [Event.from_dict(e) for e in data.get('events', [])]
data['decisions'] = [Decision.from_dict(d) for d in data.get('decisions', [])]
return cls(**data)
class GovernanceMemory:
"""
Memory system for tracking governance state.
This provides a queryable interface for the LLM to understand current state
and update it with new events and decisions.
"""
def __init__(self, db_session):
"""Initialize with database session for persistence"""
self.db = db_session
self._cache: Dict[str, ProcessMemory] = {}
# Query operations
def get_process(self, process_id: str) -> Optional[ProcessMemory]:
"""Retrieve a specific process by ID"""
if process_id in self._cache:
return self._cache[process_id]
# Load from database
from .db import queries
db_process = queries.get_process(self.db, int(process_id.split('_')[-1]))
if db_process:
memory = self._db_to_memory(db_process)
self._cache[process_id] = memory
return memory
return None
def query_processes(
self,
status: Optional[ProcessStatus] = None,
process_type: Optional[str] = None,
created_by: Optional[str] = None,
has_deadline: Optional[bool] = None,
deadline_before: Optional[datetime] = None,
limit: int = 50
) -> List[ProcessMemory]:
"""
Query processes by criteria.
This is the primary way the LLM discovers relevant processes.
"""
from .db import queries
# Build query
db_processes = queries.get_active_processes(self.db)
# Filter in memory (could optimize with SQL)
results = []
for db_proc in db_processes:
memory = self._db_to_memory(db_proc)
# Apply filters
if status and memory.status != status:
continue
if process_type and memory.type != process_type:
continue
if created_by and memory.created_by != created_by:
continue
if has_deadline is not None:
if has_deadline and not memory.deadline:
continue
if not has_deadline and memory.deadline:
continue
if deadline_before and memory.deadline:
if memory.deadline >= deadline_before:
continue
results.append(memory)
self._cache[memory.id] = memory
if len(results) >= limit:
break
return results
def get_active_processes(self) -> List[ProcessMemory]:
"""Get all active processes (convenience method)"""
return self.query_processes(status=ProcessStatus.ACTIVE)
def get_overdue_processes(self) -> List[ProcessMemory]:
"""Get processes past their deadline"""
return self.query_processes(
status=ProcessStatus.ACTIVE,
deadline_before=datetime.utcnow()
)
def search_decisions(
self,
decision_type: Optional[str] = None,
has_citation: Optional[str] = None,
limit: int = 20
) -> List[Decision]:
"""
Search past decisions (for precedent).
This helps the LLM find similar past decisions.
"""
all_decisions = []
# Get completed processes
completed = self.query_processes(status=ProcessStatus.COMPLETED, limit=100)
for proc in completed:
for decision in proc.decisions:
# Apply filters
if decision_type and decision.decision_type != decision_type:
continue
if has_citation and has_citation not in decision.constitution_citations:
continue
all_decisions.append(decision)
if len(all_decisions) >= limit:
return all_decisions
return all_decisions
# Update operations
def create_process(
self,
process_id: str,
process_type: str,
created_by: str,
constitution_basis: List[str],
deadline: Optional[datetime] = None,
initial_state: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None
) -> ProcessMemory:
"""Create a new process in memory"""
memory = ProcessMemory(
id=process_id,
type=process_type,
status=ProcessStatus.ACTIVE,
created_at=datetime.utcnow(),
created_by=created_by,
deadline=deadline,
constitution_basis=constitution_basis,
state=initial_state or {},
metadata=metadata or {}
)
self._cache[process_id] = memory
self._persist_memory(memory)
return memory
def update_process(
self,
process_id: str,
status: Optional[ProcessStatus] = None,
state_updates: Optional[Dict[str, Any]] = None,
new_deadline: Optional[datetime] = None
) -> ProcessMemory:
"""Update an existing process"""
memory = self.get_process(process_id)
if not memory:
raise ValueError(f"Process {process_id} not found")
if status:
memory.status = status
if state_updates:
memory.update_state(state_updates)
if new_deadline:
memory.deadline = new_deadline
self._persist_memory(memory)
return memory
def add_event(
self,
process_id: str,
actor: str,
event_type: str,
data: Dict[str, Any],
context: str
) -> Event:
"""Add an event to a process"""
memory = self.get_process(process_id)
if not memory:
raise ValueError(f"Process {process_id} not found")
event = Event(
timestamp=datetime.utcnow(),
actor=actor,
event_type=event_type,
data=data,
context=context
)
memory.add_event(event)
self._persist_memory(memory)
return event
def add_decision(
self,
process_id: str,
decision_type: str,
reasoning: str,
constitution_citations: List[str],
result: Any,
calculation_used: Optional[str] = None,
calculation_variables: Optional[Dict[str, Any]] = None,
calculation_result: Optional[Any] = None,
precedent_references: Optional[List[str]] = None
) -> Decision:
"""Add a decision to a process"""
memory = self.get_process(process_id)
if not memory:
raise ValueError(f"Process {process_id} not found")
decision = Decision(
timestamp=datetime.utcnow(),
decision_type=decision_type,
reasoning=reasoning,
constitution_citations=constitution_citations,
calculation_used=calculation_used,
calculation_variables=calculation_variables,
calculation_result=calculation_result,
result=result,
precedent_references=precedent_references or []
)
memory.add_decision(decision)
self._persist_memory(memory)
return decision
# Persistence
def _persist_memory(self, memory: ProcessMemory):
"""Save memory to database"""
from .db import queries
# Convert process_id to int for database
db_id = int(memory.id.split('_')[-1]) if '_' in memory.id else int(memory.id)
# Check if exists
db_process = queries.get_process(self.db, db_id)
if db_process:
# Update existing
queries.update_process_state(
self.db,
db_id,
{
"status": memory.status.value,
"state": memory.state,
"events": [e.to_dict() for e in memory.events],
"decisions": [d.to_dict() for d in memory.decisions]
}
)
else:
# Create new
queries.create_process(
session=self.db,
process_type=memory.type,
creator=memory.created_by,
constitutional_basis=", ".join(memory.constitution_basis),
deadline=memory.deadline or datetime.utcnow(),
state_data=memory.state
)
def _db_to_memory(self, db_process) -> ProcessMemory:
"""Convert database process to memory format"""
# Extract events and decisions from state_data if present
state_data = db_process.state_data or {}
events = [Event.from_dict(e) for e in state_data.get('events', [])]
decisions = [Decision.from_dict(d) for d in state_data.get('decisions', [])]
return ProcessMemory(
id=f"process_{db_process.id}",
type=db_process.process_type,
status=ProcessStatus(db_process.status),
created_at=db_process.created_at,
created_by=db_process.creator,
deadline=db_process.deadline,
constitution_basis=[db_process.constitutional_basis] if db_process.constitutional_basis else [],
state=state_data.get('state', state_data), # Migrate format
events=events,
decisions=decisions,
metadata={"mastodon_thread_id": db_process.mastodon_thread_id} if db_process.mastodon_thread_id else {}
)
# Utility
def summarize_for_llm(self, process_id: str) -> str:
"""
Create a human-readable summary of a process for LLM context.
This helps the LLM understand process state without overwhelming context.
"""
memory = self.get_process(process_id)
if not memory:
return f"Process {process_id} not found."
summary = f"""
Process: {memory.id}
Type: {memory.type}
Status: {memory.status.value}
Created: {memory.created_at.strftime('%Y-%m-%d %H:%M UTC')} by {memory.created_by}
Constitutional Basis: {', '.join(memory.constitution_basis)}
"""
if memory.deadline:
summary += f"Deadline: {memory.deadline.strftime('%Y-%m-%d %H:%M UTC')}\n"
# Add state summary
summary += f"\nCurrent State:\n"
for key, value in memory.state.items():
if isinstance(value, dict):
summary += f" {key}: {len(value)} items\n"
elif isinstance(value, list):
summary += f" {key}: {len(value)} items\n"
else:
summary += f" {key}: {value}\n"
# Add recent events
if memory.events:
summary += f"\nRecent Events ({len(memory.events)} total):\n"
for event in memory.events[-5:]: # Last 5 events
summary += f" [{event.timestamp.strftime('%m-%d %H:%M')}] {event.context}\n"
# Add decisions
if memory.decisions:
summary += f"\nDecisions ({len(memory.decisions)} total):\n"
for decision in memory.decisions:
summary += f" [{decision.timestamp.strftime('%m-%d %H:%M')}] {decision.decision_type}: {decision.result}\n"
return summary