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:
@@ -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
103
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 <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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Discord Configuration
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user