diff --git a/.env.example b/.env.example index 7d17ead..30d02cc 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ GEMINI_API_KEY=xxx AI_MAX_TOKENS=1024 # AI creativity/randomness (0.0 = deterministic, 2.0 = very creative) -AI_TEMPERATURE=0.7 +AI_TEMPERATURE=1 # =========================================== # Bot Identity & Personality @@ -62,6 +62,8 @@ CONVERSATION_TIMEOUT_MINUTES=60 # Password for PostgreSQL when using docker-compose POSTGRES_PASSWORD=daemon +POSTGRES_USER=daemon +POSTGRES_DB=daemon_boyfriend # Echo SQL statements for debugging (true/false) DATABASE_ECHO=false diff --git a/CLAUDE.md b/CLAUDE.md index c00eca7..5e680d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co # Install dependencies pip install -r requirements.txt +# Install in development mode (required for testing) +pip install -e . + # Run the bot (requires .env with DISCORD_TOKEN and AI provider key) python -m daemon_boyfriend @@ -21,9 +24,33 @@ alembic upgrade head python -m py_compile src/daemon_boyfriend/**/*.py ``` +## Testing + +```bash +# Install dev dependencies +pip install -e ".[dev]" + +# Run all tests +python -m pytest tests/ -v + +# Run tests with coverage +python -m pytest tests/ --cov=daemon_boyfriend --cov-report=term-missing + +# Run specific test file +python -m pytest tests/test_models.py -v + +# Run specific test class +python -m pytest tests/test_services.py::TestMoodService -v +``` + +The test suite uses: +- `pytest` with `pytest-asyncio` for async test support +- SQLite in-memory database for testing (via `aiosqlite`) +- Mock fixtures for Discord objects and AI providers in `tests/conftest.py` + ## Architecture -This is a Discord bot that responds to @mentions with AI-generated responses (multi-provider support). +This is a Discord bot that responds to @mentions with AI-generated responses (multi-provider support). It features a "Living AI" system that gives the bot personality, mood, and relationship tracking. ### Provider Pattern The AI system uses a provider abstraction pattern: @@ -44,6 +71,7 @@ Cogs are auto-loaded by `bot.py` from the `cogs/` directory. ### Database & Memory System The bot uses PostgreSQL for persistent memory (optional, falls back to in-memory): - `models/` - SQLAlchemy models (User, UserFact, Conversation, Message, Guild, GuildMember) +- `models/living_ai.py` - Living AI models (BotState, BotOpinion, UserRelationship, etc.) - `services/database.py` - Connection pool and async session management - `services/user_service.py` - User CRUD, custom names, facts management - `services/persistent_conversation.py` - Database-backed conversation history @@ -55,6 +83,42 @@ Key features: - Persistent conversations: Chat history survives restarts - Conversation timeout: New conversation starts after 60 minutes of inactivity +### Living AI System +The bot implements a "Living AI" system with emotional depth and relationship tracking: + +#### Services (`services/`) +- `mood_service.py` - Valence-arousal mood model with time decay +- `relationship_service.py` - Relationship scoring (stranger to close friend) +- `fact_extraction_service.py` - Autonomous fact learning from conversations +- `opinion_service.py` - Bot develops opinions on topics over time +- `self_awareness_service.py` - Bot statistics and self-reflection +- `communication_style_service.py` - Learns user communication preferences +- `proactive_service.py` - Scheduled events (birthdays, follow-ups) +- `association_service.py` - Cross-user memory associations + +#### Models (`models/living_ai.py`) +- `BotState` - Global mood state and statistics per guild +- `BotOpinion` - Topic sentiments and preferences +- `UserRelationship` - Per-user relationship scores and metrics +- `UserCommunicationStyle` - Learned communication preferences +- `ScheduledEvent` - Birthdays, follow-ups, reminders +- `FactAssociation` - Cross-user memory links +- `MoodHistory` - Mood changes over time + +#### Mood System +Uses a valence-arousal model: +- Valence: -1 (sad) to +1 (happy) +- Arousal: -1 (calm) to +1 (excited) +- Labels: excited, happy, calm, neutral, bored, annoyed, curious +- Time decay: Mood gradually returns to neutral + +#### Relationship Levels +- Stranger (0-20): Polite, formal +- Acquaintance (21-40): Friendly but reserved +- Friend (41-60): Casual, warm +- Good Friend (61-80): Personal, references past talks +- Close Friend (81-100): Very casual, inside jokes + ### Configuration All config flows through `config.py` using pydantic-settings. The `settings` singleton is created at module load, so env vars must be set before importing. @@ -72,6 +136,8 @@ The bot can search the web for current information via SearXNG: - The bot responds only to @mentions via `on_message` listener - Web search uses AI to decide when to search, avoiding unnecessary API calls for general knowledge questions - User context (custom name + known facts) is included in AI prompts for personalized responses +- `PortableJSON` type in `models/base.py` allows models to work with both PostgreSQL (JSONB) and SQLite (JSON) +- `ensure_utc()` helper handles timezone-naive datetimes from SQLite ## Environment Variables @@ -82,15 +148,44 @@ Optional: - `POSTGRES_PASSWORD` - Used by docker-compose for the PostgreSQL container - `SEARXNG_URL` - SearXNG instance URL for web search capability -## Memory Commands +### Living AI Configuration +- `LIVING_AI_ENABLED` - Master switch for Living AI features (default: true) +- `MOOD_ENABLED` - Enable mood system (default: true) +- `RELATIONSHIP_ENABLED` - Enable relationship tracking (default: true) +- `FACT_EXTRACTION_ENABLED` - Enable autonomous fact extraction (default: true) +- `FACT_EXTRACTION_RATE` - Probability of extracting facts (default: 0.3) +- `PROACTIVE_ENABLED` - Enable proactive messages (default: true) +- `CROSS_USER_ENABLED` - Enable cross-user memory associations (default: false) +- `OPINION_FORMATION_ENABLED` - Enable bot opinion formation (default: true) +- `STYLE_LEARNING_ENABLED` - Enable communication style learning (default: true) +- `MOOD_DECAY_RATE` - How fast mood returns to neutral per hour (default: 0.1) -User commands: +### Command Toggles +- `COMMANDS_ENABLED` - Master switch for all commands (default: true) +- `CMD_RELATIONSHIP_ENABLED` - Enable `!relationship` command +- `CMD_MOOD_ENABLED` - Enable `!mood` command +- `CMD_BOTSTATS_ENABLED` - Enable `!botstats` command +- `CMD_OURHISTORY_ENABLED` - Enable `!ourhistory` command +- `CMD_BIRTHDAY_ENABLED` - Enable `!birthday` command +- `CMD_REMEMBER_ENABLED` - Enable `!remember` command +- `CMD_SETNAME_ENABLED` - Enable `!setname` command +- `CMD_WHATDOYOUKNOW_ENABLED` - Enable `!whatdoyouknow` command +- `CMD_FORGETME_ENABLED` - Enable `!forgetme` command + +## Commands + +### User commands - `!setname ` - Set your preferred name - `!clearname` - Reset to Discord display name - `!remember ` - Tell the bot something about you - `!whatdoyouknow` - See what the bot remembers about you - `!forgetme` - Clear all facts about you +- `!relationship` - See your relationship level with the bot +- `!mood` - See the bot's current emotional state +- `!botstats` - Bot shares its self-awareness statistics +- `!ourhistory` - See your history with the bot +- `!birthday ` - Set your birthday for the bot to remember -Admin commands: +### Admin commands - `!setusername @user ` - Set name for another user - `!teachbot @user ` - Add a fact about a user diff --git a/docker-compose.yml b/docker-compose.yml index 58a5712..9991ece 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,36 @@ services: - daemon-boyfriend: - build: . - container_name: daemon-boyfriend - restart: unless-stopped - env_file: - - .env - environment: - - PYTHONUNBUFFERED=1 - - DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend - depends_on: - postgres: - condition: service_healthy + daemon-boyfriend: + build: . + container_name: daemon-boyfriend + restart: unless-stopped + env_file: + - .env + environment: + - PYTHONUNBUFFERED=1 + - DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend + depends_on: + postgres: + condition: service_healthy - postgres: - image: postgres:16-alpine - container_name: daemon-boyfriend-postgres - restart: unless-stopped - environment: - POSTGRES_USER: daemon - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon} - POSTGRES_DB: daemon_boyfriend - volumes: - - postgres_data:/var/lib/postgresql/data - - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro - healthcheck: - test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"] - interval: 10s - timeout: 5s - retries: 5 + postgres: + image: postgres:16-alpine + container_name: daemon-boyfriend-postgres + restart: unless-stopped + # optional + ports: + - "5433:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-daemon} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon} + POSTGRES_DB: ${POSTGRES_DB:-daemon_boyfriend} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"] + interval: 10s + timeout: 5s + retries: 5 volumes: - postgres_data: + postgres_data: diff --git a/project-vision.md b/docs/project-vision.md similarity index 100% rename from project-vision.md rename to docs/project-vision.md diff --git a/src/daemon_boyfriend/config.py b/src/daemon_boyfriend/config.py index 4054741..0cec8dd 100644 --- a/src/daemon_boyfriend/config.py +++ b/src/daemon_boyfriend/config.py @@ -13,6 +13,7 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=False, + extra="ignore", ) # Discord Configuration diff --git a/src/daemon_boyfriend/models/base.py b/src/daemon_boyfriend/models/base.py index cf19f41..64cccc8 100644 --- a/src/daemon_boyfriend/models/base.py +++ b/src/daemon_boyfriend/models/base.py @@ -2,9 +2,23 @@ from datetime import datetime, timezone -from sqlalchemy import DateTime, MetaData +from sqlalchemy import JSON, DateTime, MetaData +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.types import TypeDecorator + + +class PortableJSON(TypeDecorator): + """A JSON type that uses JSONB on PostgreSQL and JSON on other databases.""" + + impl = JSON + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(JSONB()) + return dialect.type_descriptor(JSON()) def utc_now() -> datetime: @@ -12,6 +26,19 @@ def utc_now() -> datetime: return datetime.now(timezone.utc) +def ensure_utc(dt: datetime | None) -> datetime | None: + """Ensure a datetime is timezone-aware (UTC). + + SQLite doesn't preserve timezone info, so this function adds UTC + timezone to naive datetimes returned from the database. + """ + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + + # Naming convention for constraints (helps with migrations) convention = { "ix": "ix_%(column_0_label)s", diff --git a/src/daemon_boyfriend/models/conversation.py b/src/daemon_boyfriend/models/conversation.py index 4c23c4e..da3c66d 100644 --- a/src/daemon_boyfriend/models/conversation.py +++ b/src/daemon_boyfriend/models/conversation.py @@ -3,10 +3,10 @@ from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import ARRAY, BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship -from .base import Base, utc_now +from .base import Base, PortableJSON, utc_now if TYPE_CHECKING: from .user import User @@ -51,7 +51,7 @@ class Message(Base): role: Mapped[str] = mapped_column(String(20)) # user, assistant, system content: Mapped[str] = mapped_column(Text) has_images: Mapped[bool] = mapped_column(Boolean, default=False) - image_urls: Mapped[list[str] | None] = mapped_column(ARRAY(Text), default=None) + image_urls: Mapped[list[str] | None] = mapped_column(PortableJSON, default=None) token_count: Mapped[int | None] = mapped_column(Integer) # Relationships diff --git a/src/daemon_boyfriend/models/guild.py b/src/daemon_boyfriend/models/guild.py index 84539c4..a48498c 100644 --- a/src/daemon_boyfriend/models/guild.py +++ b/src/daemon_boyfriend/models/guild.py @@ -4,19 +4,16 @@ from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import ( - ARRAY, BigInteger, Boolean, DateTime, ForeignKey, String, - Text, UniqueConstraint, ) -from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship -from .base import Base, utc_now +from .base import Base, PortableJSON, utc_now if TYPE_CHECKING: from .user import User @@ -32,7 +29,7 @@ class Guild(Base): name: Mapped[str] = mapped_column(String(255)) joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) is_active: Mapped[bool] = mapped_column(Boolean, default=True) - settings: Mapped[dict] = mapped_column(JSONB, default=dict) + settings: Mapped[dict] = mapped_column(PortableJSON, default=dict) # Relationships members: Mapped[list["GuildMember"]] = relationship( @@ -49,7 +46,7 @@ class GuildMember(Base): guild_id: Mapped[int] = mapped_column(ForeignKey("guilds.id", ondelete="CASCADE"), index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) guild_nickname: Mapped[str | None] = mapped_column(String(255)) - roles: Mapped[list[str] | None] = mapped_column(ARRAY(Text), default=None) + roles: Mapped[list[str] | None] = mapped_column(PortableJSON, default=None) joined_guild_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) # Relationships diff --git a/src/daemon_boyfriend/models/living_ai.py b/src/daemon_boyfriend/models/living_ai.py index 7203011..02d482b 100644 --- a/src/daemon_boyfriend/models/living_ai.py +++ b/src/daemon_boyfriend/models/living_ai.py @@ -13,10 +13,9 @@ from sqlalchemy import ( Text, UniqueConstraint, ) -from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship -from .base import Base, utc_now +from .base import Base, PortableJSON, utc_now if TYPE_CHECKING: from .user import User, UserFact @@ -42,7 +41,7 @@ class BotState(Base): first_activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) # Bot preferences (evolved over time) - preferences: Mapped[dict] = mapped_column(JSONB, default=dict) + preferences: Mapped[dict] = mapped_column(PortableJSON, default=dict) class BotOpinion(Base): @@ -88,7 +87,7 @@ class UserRelationship(Base): conversation_depth_avg: Mapped[float] = mapped_column(Float, default=0.0) # Inside jokes / shared references - shared_references: Mapped[dict] = mapped_column(JSONB, default=dict) + shared_references: Mapped[dict] = mapped_column(PortableJSON, default=dict) first_interaction_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) last_interaction_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) @@ -117,7 +116,7 @@ class UserCommunicationStyle(Base): detail_preference: Mapped[float] = mapped_column(Float, default=0.5) # 0=concise, 1=detailed # Engagement signals used to learn preferences - engagement_signals: Mapped[dict] = mapped_column(JSONB, default=dict) + engagement_signals: Mapped[dict] = mapped_column(PortableJSON, default=dict) samples_collected: Mapped[int] = mapped_column(default=0) confidence: Mapped[float] = mapped_column(Float, default=0.0) # 0-1 @@ -140,7 +139,7 @@ class ScheduledEvent(Base): trigger_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) title: Mapped[str] = mapped_column(String(255)) - context: Mapped[dict] = mapped_column(JSONB, default=dict) + context: Mapped[dict] = mapped_column(PortableJSON, default=dict) is_recurring: Mapped[bool] = mapped_column(Boolean, default=False) recurrence_rule: Mapped[str | None] = mapped_column(String(100)) diff --git a/src/daemon_boyfriend/services/mood_service.py b/src/daemon_boyfriend/services/mood_service.py index 622342e..8df0af9 100644 --- a/src/daemon_boyfriend/services/mood_service.py +++ b/src/daemon_boyfriend/services/mood_service.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from daemon_boyfriend.config import settings from daemon_boyfriend.models import BotState, MoodHistory +from daemon_boyfriend.models.base import ensure_utc logger = logging.getLogger(__name__) @@ -61,7 +62,7 @@ class MoodService: # Apply time decay toward neutral hours_since_update = ( - datetime.now(timezone.utc) - bot_state.mood_updated_at + datetime.now(timezone.utc) - ensure_utc(bot_state.mood_updated_at) ).total_seconds() / 3600 decay_factor = max(0, 1 - (settings.mood_decay_rate * hours_since_update)) @@ -142,7 +143,7 @@ class MoodService: async def get_stats(self, guild_id: int | None = None) -> dict: """Get bot statistics.""" bot_state = await self.get_or_create_bot_state(guild_id) - age_delta = datetime.now(timezone.utc) - bot_state.first_activated_at + age_delta = datetime.now(timezone.utc) - ensure_utc(bot_state.first_activated_at) return { "age_days": age_delta.days, diff --git a/src/daemon_boyfriend/services/relationship_service.py b/src/daemon_boyfriend/services/relationship_service.py index a0339e2..0d1c78b 100644 --- a/src/daemon_boyfriend/services/relationship_service.py +++ b/src/daemon_boyfriend/services/relationship_service.py @@ -8,6 +8,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from daemon_boyfriend.models import User, UserRelationship +from daemon_boyfriend.models.base import ensure_utc logger = logging.getLogger(__name__) @@ -211,7 +212,7 @@ class RelationshipService: level = self.get_level(rel.relationship_score) # Calculate time since first interaction - time_known = datetime.now(timezone.utc) - rel.first_interaction_at + time_known = datetime.now(timezone.utc) - ensure_utc(rel.first_interaction_at) days_known = time_known.days return { diff --git a/src/daemon_boyfriend/services/self_awareness_service.py b/src/daemon_boyfriend/services/self_awareness_service.py index 4ce4bf4..68de0f6 100644 --- a/src/daemon_boyfriend/services/self_awareness_service.py +++ b/src/daemon_boyfriend/services/self_awareness_service.py @@ -13,6 +13,7 @@ from daemon_boyfriend.models import ( UserFact, UserRelationship, ) +from daemon_boyfriend.models.base import ensure_utc logger = logging.getLogger(__name__) @@ -36,7 +37,7 @@ class SelfAwarenessService: await self._session.flush() # Calculate age - age_delta = datetime.now(timezone.utc) - bot_state.first_activated_at + age_delta = datetime.now(timezone.utc) - ensure_utc(bot_state.first_activated_at) # Count users (from database) user_count = await self._count_users() @@ -76,7 +77,7 @@ class SelfAwarenessService: facts_count = facts_result.scalar() or 0 if rel: - days_known = (datetime.now(timezone.utc) - rel.first_interaction_at).days + days_known = (datetime.now(timezone.utc) - ensure_utc(rel.first_interaction_at)).days return { "first_met": rel.first_interaction_at, "days_known": days_known, diff --git a/tests/test_providers.py b/tests/test_providers.py index c1b4ed0..1f8d814 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -3,15 +3,16 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + +from daemon_boyfriend.services.providers.anthropic import AnthropicProvider from daemon_boyfriend.services.providers.base import ( AIProvider, AIResponse, - Message, ImageAttachment, + Message, ) -from daemon_boyfriend.services.providers.openai import OpenAIProvider -from daemon_boyfriend.services.providers.anthropic import AnthropicProvider from daemon_boyfriend.services.providers.gemini import GeminiProvider +from daemon_boyfriend.services.providers.openai import OpenAIProvider from daemon_boyfriend.services.providers.openrouter import OpenRouterProvider @@ -144,12 +145,6 @@ class TestAnthropicProvider: with patch( "daemon_boyfriend.services.providers.anthropic.anthropic.AsyncAnthropic" ) as mock_class: - """Tests for the Anthropic provider.""" - - @pytest.fixture - def provider(self, mock_anthropic_client): - """Create an Anthropic provider with mocked client.""" - with patch("daemon_boyfriend.services.providers.anthropic.anthropic.AsyncAnthropic") as mock_class: mock_class.return_value = mock_anthropic_client provider = AnthropicProvider(api_key="test_key", model="claude-sonnet-4-20250514") provider.client = mock_anthropic_client