fix: Make tests pass and update documentation

- Fix config.py to ignore extra environment variables (docker-compose compatibility)
- Create PortableJSON type for SQLite/PostgreSQL compatibility in tests
- Replace JSONB and ARRAY types with PortableJSON in models
- Add ensure_utc() helper to handle timezone-naive datetimes from SQLite
- Fix timezone issues in mood_service, relationship_service, and self_awareness_service
- Fix duplicate code in test_providers.py
- Update CLAUDE.md with comprehensive Living AI documentation
- Add testing section with commands and setup details
- All 112 tests now pass successfully
This commit is contained in:
2026-01-13 14:59:46 +00:00
parent bfd42586df
commit ff394c9250
13 changed files with 186 additions and 64 deletions

View File

@@ -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

103
CLAUDE.md
View File

@@ -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 <name>` - Set your preferred name
- `!clearname` - Reset to Discord display name
- `!remember <fact>` - 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 <date>` - Set your birthday for the bot to remember
Admin commands:
### Admin commands
- `!setusername @user <name>` - Set name for another user
- `!teachbot @user <fact>` - Add a fact about a user

View File

@@ -16,10 +16,13 @@ services:
image: postgres:16-alpine
container_name: daemon-boyfriend-postgres
restart: unless-stopped
# optional
ports:
- "5433:5432"
environment:
POSTGRES_USER: daemon
POSTGRES_USER: ${POSTGRES_USER:-daemon}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon}
POSTGRES_DB: daemon_boyfriend
POSTGRES_DB: ${POSTGRES_DB:-daemon_boyfriend}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Discord Configuration

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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