Merge pull request 'dev' (#17) from dev into main

Reviewed-on: MSC/Daemon-Boyfriend#17
This commit is contained in:
2026-01-13 18:38:48 +00:00
25 changed files with 5139 additions and 65 deletions

View File

@@ -23,7 +23,7 @@ GEMINI_API_KEY=xxx
AI_MAX_TOKENS=1024 AI_MAX_TOKENS=1024
# AI creativity/randomness (0.0 = deterministic, 2.0 = very creative) # AI creativity/randomness (0.0 = deterministic, 2.0 = very creative)
AI_TEMPERATURE=0.7 AI_TEMPERATURE=1
# =========================================== # ===========================================
# Bot Identity & Personality # Bot Identity & Personality
@@ -62,6 +62,8 @@ CONVERSATION_TIMEOUT_MINUTES=60
# Password for PostgreSQL when using docker-compose # Password for PostgreSQL when using docker-compose
POSTGRES_PASSWORD=daemon POSTGRES_PASSWORD=daemon
POSTGRES_USER=daemon
POSTGRES_DB=daemon_boyfriend
# Echo SQL statements for debugging (true/false) # Echo SQL statements for debugging (true/false)
DATABASE_ECHO=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 # Install dependencies
pip install -r requirements.txt 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) # Run the bot (requires .env with DISCORD_TOKEN and AI provider key)
python -m daemon_boyfriend python -m daemon_boyfriend
@@ -21,9 +24,33 @@ alembic upgrade head
python -m py_compile src/daemon_boyfriend/**/*.py 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 ## 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 ### Provider Pattern
The AI system uses a provider abstraction 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 ### Database & Memory System
The bot uses PostgreSQL for persistent memory (optional, falls back to in-memory): The bot uses PostgreSQL for persistent memory (optional, falls back to in-memory):
- `models/` - SQLAlchemy models (User, UserFact, Conversation, Message, Guild, GuildMember) - `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/database.py` - Connection pool and async session management
- `services/user_service.py` - User CRUD, custom names, facts management - `services/user_service.py` - User CRUD, custom names, facts management
- `services/persistent_conversation.py` - Database-backed conversation history - `services/persistent_conversation.py` - Database-backed conversation history
@@ -55,6 +83,42 @@ Key features:
- Persistent conversations: Chat history survives restarts - Persistent conversations: Chat history survives restarts
- Conversation timeout: New conversation starts after 60 minutes of inactivity - 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 ### 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. 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 - 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 - 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 - 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 ## Environment Variables
@@ -82,15 +148,44 @@ Optional:
- `POSTGRES_PASSWORD` - Used by docker-compose for the PostgreSQL container - `POSTGRES_PASSWORD` - Used by docker-compose for the PostgreSQL container
- `SEARXNG_URL` - SearXNG instance URL for web search capability - `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 - `!setname <name>` - Set your preferred name
- `!clearname` - Reset to Discord display name - `!clearname` - Reset to Discord display name
- `!remember <fact>` - Tell the bot something about you - `!remember <fact>` - Tell the bot something about you
- `!whatdoyouknow` - See what the bot remembers about you - `!whatdoyouknow` - See what the bot remembers about you
- `!forgetme` - Clear all facts 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 - `!setusername @user <name>` - Set name for another user
- `!teachbot @user <fact>` - Add a fact about a user - `!teachbot @user <fact>` - Add a fact about a user

View File

@@ -1,33 +1,36 @@
services: services:
daemon-boyfriend: daemon-boyfriend:
build: . build: .
container_name: daemon-boyfriend container_name: daemon-boyfriend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend - DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: daemon-boyfriend-postgres container_name: daemon-boyfriend-postgres
restart: unless-stopped restart: unless-stopped
environment: # optional
POSTGRES_USER: daemon ports:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon} - "5433:5432"
POSTGRES_DB: daemon_boyfriend environment:
volumes: POSTGRES_USER: ${POSTGRES_USER:-daemon}
- postgres_data:/var/lib/postgresql/data POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon}
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro POSTGRES_DB: ${POSTGRES_DB:-daemon_boyfriend}
healthcheck: volumes:
test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"] - postgres_data:/var/lib/postgresql/data
interval: 10s - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
timeout: 5s healthcheck:
retries: 5 test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
postgres_data: postgres_data:

195
docs/README.md Normal file
View File

@@ -0,0 +1,195 @@
# Daemon Boyfriend - Technical Documentation
Welcome to the technical documentation for Daemon Boyfriend, a Discord bot with AI-powered personality, emotional depth, and relationship awareness.
## Quick Navigation
| Document | Description |
|----------|-------------|
| [Architecture Overview](architecture.md) | High-level system design, components, data flow |
| [Living AI System](living-ai/README.md) | Mood, relationships, fact extraction, opinions |
| [Services Reference](services/README.md) | API documentation for all services |
| [Database Schema](database.md) | Complete database schema reference |
| [Configuration](configuration.md) | All environment variables and settings |
| [Developer Guides](guides/README.md) | How to extend and develop the bot |
---
## What is Daemon Boyfriend?
Daemon Boyfriend is a Discord bot that goes beyond simple chatbot functionality. It features a sophisticated "Living AI" system that gives the bot:
- **Emotional States** - A mood system based on psychological valence-arousal model
- **Relationship Tracking** - Evolving relationships from stranger to close friend
- **Memory** - Learns and remembers facts about users
- **Opinions** - Develops opinions on topics through discussion
- **Personality Adaptation** - Learns each user's communication style
---
## Architecture at a Glance
```
┌─────────────────────────────────────────────────────────────────┐
│ Discord API │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Discord Bot │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ AIChatCog │ │ MemoryCog │ │ StatusCog │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐
│ Core Services │ │ Living AI │ │ AI Providers │
│ │ │ │ │ │
│ UserService │ │ MoodService │ │ OpenAI / Anthropic / │
│ DatabaseService│ │ RelationshipSvc│ │ Gemini / OpenRouter │
│ ConversationMgr│ │ FactExtraction │ │ │
│ SearXNGService │ │ OpinionService │ │ │
└────────────────┘ └────────────────┘ └────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
└─────────────────────────────────────────────────────────────────┘
```
---
## Documentation Structure
### Core Documentation
| File | Contents |
|------|----------|
| [architecture.md](architecture.md) | System architecture, design patterns, component interactions |
| [database.md](database.md) | Database schema, tables, indexes, relationships |
| [configuration.md](configuration.md) | All configuration options with examples |
### Living AI Deep Dives
| File | Contents |
|------|----------|
| [living-ai/README.md](living-ai/README.md) | Overview of all Living AI systems |
| [living-ai/mood-system.md](living-ai/mood-system.md) | Valence-arousal model, mood labels, decay |
| [living-ai/relationship-system.md](living-ai/relationship-system.md) | Relationship levels, scoring algorithm |
| [living-ai/fact-extraction.md](living-ai/fact-extraction.md) | AI-powered fact learning, deduplication |
| [living-ai/opinion-system.md](living-ai/opinion-system.md) | Topic sentiment, interest tracking |
### Developer Resources
| File | Contents |
|------|----------|
| [services/README.md](services/README.md) | Complete API reference for all services |
| [guides/README.md](guides/README.md) | How to add providers, commands, features |
---
## Key Concepts
### Message Flow
1. User @mentions the bot in Discord
2. `AIChatCog` receives the message
3. User context is built (name, facts, preferences)
4. Living AI context is gathered (mood, relationship, style, opinions)
5. Enhanced system prompt is constructed
6. AI provider generates response
7. Living AI systems are updated (mood, relationship, facts)
8. Response is sent to Discord
### Living AI Components
| Component | Purpose | Update Frequency |
|-----------|---------|------------------|
| **Mood** | Emotional state | Every interaction |
| **Relationship** | User familiarity | Every interaction |
| **Facts** | User knowledge | ~30% of messages |
| **Opinions** | Topic preferences | When topics discussed |
| **Style** | Communication preferences | Rolling 50 messages |
### Database Modes
| Mode | Configuration | Use Case |
|------|---------------|----------|
| **PostgreSQL** | `DATABASE_URL` set | Production, persistence |
| **In-Memory** | `DATABASE_URL` not set | Development, testing |
---
## Quick Start
### 1. Install
```bash
pip install -r requirements.txt
pip install -e .
```
### 2. Configure
```bash
# Minimum .env
DISCORD_TOKEN=your_token
OPENAI_API_KEY=your_key
```
### 3. Run
```bash
python -m daemon_boyfriend
```
### 4. Interact
Mention the bot in Discord:
```
@Daemon Hello! How are you today?
```
---
## Commands Reference
### User Commands
| Command | Description |
|---------|-------------|
| `!setname <name>` | Set your preferred name |
| `!clearname` | Reset to Discord name |
| `!remember <fact>` | Tell the bot something about you |
| `!whatdoyouknow` | See what the bot remembers |
| `!forgetme` | Clear all your facts |
| `!relationship` | See your relationship status |
| `!mood` | See the bot's current mood |
| `!botstats` | View bot statistics |
| `!ourhistory` | See your history with the bot |
| `!birthday <date>` | Set your birthday |
### Admin Commands
| Command | Description |
|---------|-------------|
| `!setusername @user <name>` | Set name for another user |
| `!teachbot @user <fact>` | Add a fact about a user |
| `!status` | View bot health metrics |
---
## Support
- **Project Issues:** GitHub Issues
- **Documentation Updates:** Submit a PR
---
## Document Changelog
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2024-01 | Initial documentation |

431
docs/architecture.md Normal file
View File

@@ -0,0 +1,431 @@
# Architecture Overview
This document provides a comprehensive overview of the Daemon Boyfriend Discord bot architecture.
## Table of Contents
- [High-Level Architecture](#high-level-architecture)
- [Directory Structure](#directory-structure)
- [Core Components](#core-components)
- [Data Flow](#data-flow)
- [Design Patterns](#design-patterns)
- [Component Interaction Diagram](#component-interaction-diagram)
---
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Discord API │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Discord Bot (bot.py) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ AIChatCog │ │ MemoryCog │ │ StatusCog │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌───────────────────────┐ ┌─────────────────┐ ┌─────────────────────────────┐
│ Core Services │ │ Living AI │ │ AI Providers │
│ ┌─────────────────┐ │ │ Services │ │ ┌─────────────────────┐ │
│ │ UserService │ │ │ ┌───────────┐ │ │ │ OpenAI Provider │ │
│ │ DatabaseService│ │ │ │ MoodSvc │ │ │ │ OpenRouter Provider│ │
│ │ ConversationMgr│ │ │ │ RelSvc │ │ │ │ Anthropic Provider │ │
│ │ SearXNGService │ │ │ │ FactSvc │ │ │ │ Gemini Provider │ │
│ └─────────────────┘ │ │ │ OpinionSvc│ │ │ └─────────────────────┘ │
└───────────────────────┘ │ │ StyleSvc │ │ └─────────────────────────────┘
│ │ ProactSvc │ │
│ │ AssocSvc │ │
│ │ SelfAware │ │
│ └───────────┘ │
└─────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ ┌──────────────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ Core Tables │ │ Living AI Tables │ │
│ │ users, guilds, conversations │ │ bot_states, user_relationships, │ │
│ │ messages, user_facts │ │ mood_history, scheduled_events │ │
│ └──────────────────────────────┘ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Directory Structure
```
daemon-boyfriend/
├── src/daemon_boyfriend/
│ ├── __main__.py # Entry point
│ ├── bot.py # Discord bot setup & cog loading
│ ├── config.py # Pydantic settings configuration
│ │
│ ├── cogs/ # Discord command handlers
│ │ ├── ai_chat.py # @mention response handler
│ │ ├── memory.py # Memory management commands
│ │ └── status.py # Bot status & health commands
│ │
│ ├── models/ # SQLAlchemy ORM models
│ │ ├── base.py # Base model with PortableJSON
│ │ ├── user.py # User, UserFact, UserPreference
│ │ ├── conversation.py # Conversation, Message
│ │ ├── guild.py # Guild, GuildMember
│ │ └── living_ai.py # All Living AI models
│ │
│ └── services/ # Business logic layer
│ ├── ai_service.py # AI provider factory
│ ├── database.py # Database connection management
│ ├── user_service.py # User CRUD operations
│ ├── conversation.py # In-memory conversation manager
│ ├── persistent_conversation.py # DB-backed conversations
│ ├── searxng.py # Web search integration
│ ├── monitoring.py # Health & metrics tracking
│ │
│ ├── providers/ # AI provider implementations
│ │ ├── base.py # Abstract AIProvider
│ │ ├── openai.py
│ │ ├── openrouter.py
│ │ ├── anthropic.py
│ │ └── gemini.py
│ │
│ └── [Living AI Services]
│ ├── mood_service.py
│ ├── relationship_service.py
│ ├── fact_extraction_service.py
│ ├── opinion_service.py
│ ├── communication_style_service.py
│ ├── proactive_service.py
│ ├── association_service.py
│ └── self_awareness_service.py
├── alembic/ # Database migrations
├── tests/ # Test suite
├── schema.sql # Database schema definition
├── docker-compose.yml # Docker deployment
└── requirements.txt # Python dependencies
```
---
## Core Components
### 1. Discord Bot Layer
The bot uses [discord.py](https://discordpy.readthedocs.io/) with a cog-based architecture.
**Entry Point (`__main__.py`)**
- Creates the bot instance
- Initializes database connection
- Starts the Discord event loop
**Bot Setup (`bot.py`)**
- Configures Discord intents
- Auto-loads all cogs from `cogs/` directory
- Handles bot lifecycle events
**Cogs**
| Cog | Purpose | Trigger |
|-----|---------|---------|
| `AIChatCog` | Main conversation handler | `@mention` |
| `MemoryCog` | User memory management | `!setname`, `!remember`, etc. |
| `StatusCog` | Health & metrics | `!status` (admin) |
### 2. Service Layer
Services contain all business logic and are designed to be:
- **Stateless**: No instance state, all state in database
- **Async-first**: All I/O operations are async
- **Testable**: Accept database sessions as parameters
**Core Services**
| Service | Responsibility |
|---------|----------------|
| `AIService` | Factory for AI providers, prompt enhancement |
| `DatabaseService` | Connection pool, session management |
| `UserService` | User CRUD, facts, preferences |
| `ConversationManager` | In-memory conversation history |
| `PersistentConversationManager` | Database-backed conversations |
| `SearXNGService` | Web search for current information |
| `MonitoringService` | Health checks, metrics, error tracking |
**Living AI Services** (see [Living AI documentation](living-ai/README.md))
| Service | Responsibility |
|---------|----------------|
| `MoodService` | Emotional state management |
| `RelationshipService` | User relationship tracking |
| `FactExtractionService` | Autonomous fact learning |
| `OpinionService` | Topic sentiment tracking |
| `CommunicationStyleService` | User style preferences |
| `ProactiveService` | Scheduled events & follow-ups |
| `AssociationService` | Cross-user memory linking |
| `SelfAwarenessService` | Bot statistics & reflection |
### 3. AI Provider Layer
Uses the **Provider Pattern** to support multiple AI backends.
```
┌────────────────────────────────────────────────────────────┐
│ AIService │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ get_provider() → returns appropriate AIProvider │ │
│ │ chat() → builds context and calls provider.generate │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ OpenAI Provider │ │ Anthropic Provider│ │ Gemini Provider │
│ (+ OpenRouter) │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
### 4. Data Layer
**Models** (`models/`)
- SQLAlchemy ORM with async support
- `PortableJSON` type for PostgreSQL/SQLite compatibility
- Timezone-aware UTC timestamps
**Database** (`services/database.py`)
- Async PostgreSQL via `asyncpg`
- Connection pooling
- Graceful fallback to in-memory when no database
---
## Data Flow
### Message Processing Flow
```
1. User sends @mention in Discord
2. AIChatCog.on_message() receives event
3. Get or create User via UserService
4. Get or create Conversation
(PersistentConversationManager or ConversationManager)
5. Build conversation history from messages
6. [Optional] Check if web search needed
└─► SearXNGService.search() → add results to context
7. Apply Living AI enhancements:
├─► MoodService.get_current_mood()
├─► RelationshipService.get_relationship()
├─► CommunicationStyleService.get_style()
└─► OpinionService.get_relevant_opinions()
8. Build enhanced system prompt with all context
9. AIService.chat() → Provider.generate()
10. Post-response processing:
├─► MoodService.update_mood()
├─► RelationshipService.record_interaction()
├─► CommunicationStyleService.record_message()
├─► FactExtractionService.maybe_extract_facts()
└─► ProactiveService.check_for_events()
11. Split response if > 2000 chars
12. Send response(s) to Discord
```
### Database Fallback Flow
```
┌─────────────────────────────────────────────────┐
│ Application Start │
└─────────────────────────────────────────────────┘
┌────────────────────────┐
│ DATABASE_URL set? │
└────────────────────────┘
│ │
Yes No
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────────┐
│ PostgreSQL Mode │ │ In-Memory Mode │
│ - Full persistence│ │ - ConversationManager │
│ - Living AI state │ │ - No persistence │
│ - User facts │ │ - Basic functionality │
└───────────────────┘ └───────────────────────┘
```
---
## Design Patterns
### 1. Provider Pattern (AI Services)
Abstract base class with multiple implementations.
```python
# base.py
class AIProvider(ABC):
@abstractmethod
async def generate(
self,
messages: list[Message],
system_prompt: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.7,
) -> AIResponse:
pass
# openai.py
class OpenAIProvider(AIProvider):
async def generate(...) -> AIResponse:
# OpenAI-specific implementation
```
### 2. Factory Pattern (AIService)
Creates the appropriate provider based on configuration.
```python
class AIService:
def __init__(self):
self.provider = self._create_provider()
def _create_provider(self) -> AIProvider:
match settings.ai_provider:
case "openai": return OpenAIProvider()
case "anthropic": return AnthropicProvider()
# ...
```
### 3. Repository Pattern (Services)
Services encapsulate data access logic.
```python
class UserService:
@staticmethod
async def get_user(session: AsyncSession, discord_id: int) -> User | None:
result = await session.execute(
select(User).where(User.discord_id == discord_id)
)
return result.scalar_one_or_none()
```
### 4. Cog Pattern (Discord.py)
Modular command grouping.
```python
class MemoryCog(commands.Cog):
def __init__(self, bot):
self.bot = bot
@commands.command(name="setname")
async def set_name(self, ctx, *, name: str):
# Command implementation
```
### 5. Graceful Degradation
System works with reduced functionality when components unavailable.
```python
# Database fallback
if settings.database_url:
conversation_manager = PersistentConversationManager()
else:
conversation_manager = ConversationManager() # In-memory
# Feature toggles
if settings.living_ai_enabled and settings.mood_enabled:
mood = await MoodService.get_current_mood(session, guild_id)
else:
mood = None
```
---
## Component Interaction Diagram
```
┌──────────────────────────────────────────┐
│ User Message │
└──────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ AIChatCog │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ UserService │ │ Conversation│ │ SearXNG │ │ AIService │ │
│ │ │ │ Manager │ │ Service │ │ │ │
│ │ get_user() │ │ get_history │ │ search() │ │ chat() ──────────┐ │ │
│ │ get_context │ │ add_message │ │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └──────────────────│──┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────────────┼────┐ │
│ │ Living AI Services │ │ │
│ │ │ │ │
│ │ ┌───────────┐ ┌──────────────┐ ┌───────────────┐ ┌────────────┐ │ │ │
│ │ │ MoodSvc │ │ Relationship │ │ CommStyleSvc │ │ OpinionSvc │ │ │ │
│ │ │ │ │ Svc │ │ │ │ │ │ │ │
│ │ │get_mood() │ │get_relation()│ │ get_style() │ │get_opinions│ │ │ │
│ │ │update() │ │record() │ │ record() │ │ update() │ │ │ │
│ │ └───────────┘ └──────────────┘ └───────────────┘ └────────────┘ │ │ │
│ │ │ │ │
│ │ ┌───────────────┐ ┌────────────────┐ ┌───────────────────────┐ │ │ │
│ │ │ FactExtract │ │ ProactiveSvc │ │ SelfAwarenessSvc │ │ │ │
│ │ │ │ │ │ │ │ │ │ │
│ │ │ extract() │ │ detect_events()│ │ get_stats() │ │ │ │
│ │ └───────────────┘ └────────────────┘ └───────────────────────┘ │ │ │
│ └────────────────────────────────────────────────────────────────────│────┘ │
│ │ │
└────────────────────────────────────────────────────────────────────────│──────┘
┌──────────────────────────────────────────────┐
│ AI Provider │
│ (OpenAI / Anthropic / Gemini / OpenRouter) │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Response │
└──────────────────────────────────────────────┘
```
---
## Next Steps
- [Living AI System](living-ai/README.md) - Deep dive into the personality system
- [Services Reference](services/README.md) - Detailed API documentation
- [Database Schema](database.md) - Complete schema documentation
- [Configuration Reference](configuration.md) - All configuration options
- [Developer Guides](guides/README.md) - How to extend the system

637
docs/configuration.md Normal file
View File

@@ -0,0 +1,637 @@
# Configuration Reference
Complete reference for all environment variables and configuration options.
## Table of Contents
- [Overview](#overview)
- [Required Configuration](#required-configuration)
- [AI Provider Configuration](#ai-provider-configuration)
- [Bot Identity](#bot-identity)
- [Database Configuration](#database-configuration)
- [Web Search (SearXNG)](#web-search-searxng)
- [Living AI Configuration](#living-ai-configuration)
- [Command Toggles](#command-toggles)
- [Logging Configuration](#logging-configuration)
- [Example .env File](#example-env-file)
---
## Overview
Configuration is managed via environment variables using [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/).
**Loading Order:**
1. Environment variables
2. `.env` file in project root
**Case Sensitivity:** Variable names are case-insensitive.
---
## Required Configuration
### DISCORD_TOKEN
```bash
DISCORD_TOKEN=your_discord_bot_token
```
**Required.** Your Discord bot token from the [Discord Developer Portal](https://discord.com/developers/applications).
### API Key (one required)
At least one API key is required based on your chosen `AI_PROVIDER`:
```bash
# For OpenAI
OPENAI_API_KEY=sk-...
# For OpenRouter
OPENROUTER_API_KEY=sk-or-...
# For Anthropic
ANTHROPIC_API_KEY=sk-ant-...
# For Gemini
GEMINI_API_KEY=AIza...
```
---
## AI Provider Configuration
### AI_PROVIDER
```bash
AI_PROVIDER=openai
```
**Default:** `openai`
**Options:** `openai`, `openrouter`, `anthropic`, `gemini`
Which AI provider to use for generating responses.
### AI_MODEL
```bash
AI_MODEL=gpt-4o
```
**Default:** `gpt-4o`
The model to use. Depends on your provider:
| Provider | Example Models |
|----------|----------------|
| openai | `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo` |
| openrouter | `anthropic/claude-3.5-sonnet`, `google/gemini-pro` |
| anthropic | `claude-3-5-sonnet-20241022`, `claude-3-opus-20240229` |
| gemini | `gemini-pro`, `gemini-1.5-pro` |
### AI_MAX_TOKENS
```bash
AI_MAX_TOKENS=1024
```
**Default:** `1024`
**Range:** 100-4096
Maximum tokens in AI response.
### AI_TEMPERATURE
```bash
AI_TEMPERATURE=0.7
```
**Default:** `0.7`
**Range:** 0.0-2.0
Sampling temperature. Higher = more creative, lower = more focused.
---
## Bot Identity
### BOT_NAME
```bash
BOT_NAME=Daemon
```
**Default:** `AI Bot`
The bot's display name used in system prompts.
### BOT_PERSONALITY
```bash
BOT_PERSONALITY=friendly, witty, and helpful
```
**Default:** `helpful and friendly`
Personality description used in the default system prompt.
### BOT_DESCRIPTION
```bash
BOT_DESCRIPTION=I'm Daemon, your friendly AI companion!
```
**Default:** `I'm an AI assistant here to help you.`
Description shown when bot is mentioned without a message.
### BOT_STATUS
```bash
BOT_STATUS=for @mentions
```
**Default:** `for mentions`
Discord status message (shown as "Watching ...").
### SYSTEM_PROMPT
```bash
SYSTEM_PROMPT=You are a helpful AI assistant named Daemon. Be friendly and concise.
```
**Default:** `None` (auto-generated)
Custom system prompt. If not set, one is generated from `BOT_NAME` and `BOT_PERSONALITY`.
---
## Database Configuration
### DATABASE_URL
```bash
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/daemon_boyfriend
```
**Default:** `None` (in-memory mode)
PostgreSQL connection URL. Format: `postgresql+asyncpg://user:pass@host:port/database`
If not set, the bot runs in in-memory mode without persistence.
### DATABASE_ECHO
```bash
DATABASE_ECHO=false
```
**Default:** `false`
Log all SQL statements. Useful for debugging.
### DATABASE_POOL_SIZE
```bash
DATABASE_POOL_SIZE=5
```
**Default:** `5`
**Range:** 1-20
Number of connections in the pool.
### DATABASE_MAX_OVERFLOW
```bash
DATABASE_MAX_OVERFLOW=10
```
**Default:** `10`
**Range:** 0-30
Maximum connections beyond the pool size.
---
## Web Search (SearXNG)
### SEARXNG_URL
```bash
SEARXNG_URL=http://localhost:8888
```
**Default:** `None` (disabled)
URL of your SearXNG instance for web search capability.
### SEARXNG_ENABLED
```bash
SEARXNG_ENABLED=true
```
**Default:** `true`
Enable/disable web search even if URL is configured.
### SEARXNG_MAX_RESULTS
```bash
SEARXNG_MAX_RESULTS=5
```
**Default:** `5`
**Range:** 1-20
Maximum search results to fetch per query.
---
## Living AI Configuration
### Master Switch
#### LIVING_AI_ENABLED
```bash
LIVING_AI_ENABLED=true
```
**Default:** `true`
Master switch for all Living AI features. When disabled, the bot acts as a basic chatbot.
---
### Feature Toggles
#### MOOD_ENABLED
```bash
MOOD_ENABLED=true
```
**Default:** `true`
Enable the mood system (valence-arousal emotional model).
#### RELATIONSHIP_ENABLED
```bash
RELATIONSHIP_ENABLED=true
```
**Default:** `true`
Enable relationship tracking (0-100 score, 5 levels).
#### FACT_EXTRACTION_ENABLED
```bash
FACT_EXTRACTION_ENABLED=true
```
**Default:** `true`
Enable autonomous fact extraction from conversations.
#### FACT_EXTRACTION_RATE
```bash
FACT_EXTRACTION_RATE=0.3
```
**Default:** `0.3`
**Range:** 0.0-1.0
Probability of attempting fact extraction for each message. Lower = fewer API calls.
#### PROACTIVE_ENABLED
```bash
PROACTIVE_ENABLED=true
```
**Default:** `true`
Enable proactive messages (birthdays, follow-ups, reminders).
#### CROSS_USER_ENABLED
```bash
CROSS_USER_ENABLED=false
```
**Default:** `false`
Enable cross-user memory associations. **Privacy-sensitive** - allows linking facts between users.
#### OPINION_FORMATION_ENABLED
```bash
OPINION_FORMATION_ENABLED=true
```
**Default:** `true`
Enable bot opinion formation on topics.
#### STYLE_LEARNING_ENABLED
```bash
STYLE_LEARNING_ENABLED=true
```
**Default:** `true`
Enable learning user communication style preferences.
---
### Mood System Settings
#### MOOD_DECAY_RATE
```bash
MOOD_DECAY_RATE=0.1
```
**Default:** `0.1`
**Range:** 0.0-1.0
How fast mood returns to neutral per hour.
| Value | Effect |
|-------|--------|
| 0.0 | Mood never decays |
| 0.1 | Full decay in ~10 hours |
| 0.5 | Full decay in ~2 hours |
| 1.0 | Full decay in ~1 hour |
---
### Conversation Settings
#### MAX_CONVERSATION_HISTORY
```bash
MAX_CONVERSATION_HISTORY=20
```
**Default:** `20`
Maximum messages to keep in conversation history per user.
#### CONVERSATION_TIMEOUT_MINUTES
```bash
CONVERSATION_TIMEOUT_MINUTES=60
```
**Default:** `60`
**Range:** 5-1440
Minutes of inactivity before starting a new conversation.
---
## Command Toggles
### Master Switch
#### COMMANDS_ENABLED
```bash
COMMANDS_ENABLED=true
```
**Default:** `true`
Master switch for all commands. When disabled, no commands work.
---
### Individual Commands
| Variable | Command | Default | Description |
|----------|---------|---------|-------------|
| `CMD_RELATIONSHIP_ENABLED` | `!relationship` | `true` | View relationship status |
| `CMD_MOOD_ENABLED` | `!mood` | `true` | View bot's current mood |
| `CMD_BOTSTATS_ENABLED` | `!botstats` | `true` | View bot statistics |
| `CMD_OURHISTORY_ENABLED` | `!ourhistory` | `true` | View history with the bot |
| `CMD_BIRTHDAY_ENABLED` | `!birthday` | `true` | Set birthday |
| `CMD_REMEMBER_ENABLED` | `!remember` | `true` | Tell bot a fact |
| `CMD_SETNAME_ENABLED` | `!setname` | `true` | Set custom name |
| `CMD_WHATDOYOUKNOW_ENABLED` | `!whatdoyouknow` | `true` | View known facts |
| `CMD_FORGETME_ENABLED` | `!forgetme` | `true` | Delete all facts |
**Example:**
```bash
# Disable only the forgetme command
CMD_FORGETME_ENABLED=false
```
---
## Logging Configuration
### LOG_LEVEL
```bash
LOG_LEVEL=INFO
```
**Default:** `INFO`
**Options:** `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
Logging verbosity level.
### LOG_FILE
```bash
LOG_FILE=/var/log/daemon_boyfriend.log
```
**Default:** `None` (console only)
Path to log file. If not set, logs only to console.
### LOG_FORMAT
```bash
LOG_FORMAT=%(asctime)s - %(name)s - %(levelname)s - %(message)s
```
**Default:** `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
Python logging format string.
---
## Example .env File
```bash
# =============================================================================
# DAEMON BOYFRIEND CONFIGURATION
# =============================================================================
# -----------------------------------------------------------------------------
# REQUIRED
# -----------------------------------------------------------------------------
# Discord bot token (from Discord Developer Portal)
DISCORD_TOKEN=your_discord_bot_token_here
# AI Provider API Key (choose one based on AI_PROVIDER)
OPENAI_API_KEY=sk-your-openai-key-here
# OPENROUTER_API_KEY=sk-or-your-openrouter-key
# ANTHROPIC_API_KEY=sk-ant-your-anthropic-key
# GEMINI_API_KEY=AIza-your-gemini-key
# -----------------------------------------------------------------------------
# AI PROVIDER
# -----------------------------------------------------------------------------
# Provider: openai, openrouter, anthropic, gemini
AI_PROVIDER=openai
# Model to use (depends on provider)
AI_MODEL=gpt-4o
# Response settings
AI_MAX_TOKENS=1024
AI_TEMPERATURE=0.7
# -----------------------------------------------------------------------------
# BOT IDENTITY
# -----------------------------------------------------------------------------
BOT_NAME=Daemon
BOT_PERSONALITY=friendly, witty, and helpful
BOT_DESCRIPTION=I'm Daemon, your AI companion!
BOT_STATUS=for @mentions
# Optional: Override the entire system prompt
# SYSTEM_PROMPT=You are a helpful AI assistant named Daemon.
# -----------------------------------------------------------------------------
# DATABASE (Optional - runs in-memory if not set)
# -----------------------------------------------------------------------------
DATABASE_URL=postgresql+asyncpg://daemon:password@localhost:5432/daemon_boyfriend
# DATABASE_ECHO=false
# DATABASE_POOL_SIZE=5
# DATABASE_MAX_OVERFLOW=10
# -----------------------------------------------------------------------------
# WEB SEARCH (Optional)
# -----------------------------------------------------------------------------
# SEARXNG_URL=http://localhost:8888
# SEARXNG_ENABLED=true
# SEARXNG_MAX_RESULTS=5
# -----------------------------------------------------------------------------
# LIVING AI FEATURES
# -----------------------------------------------------------------------------
# Master switch
LIVING_AI_ENABLED=true
# Individual features
MOOD_ENABLED=true
RELATIONSHIP_ENABLED=true
FACT_EXTRACTION_ENABLED=true
FACT_EXTRACTION_RATE=0.3
PROACTIVE_ENABLED=true
CROSS_USER_ENABLED=false
OPINION_FORMATION_ENABLED=true
STYLE_LEARNING_ENABLED=true
# Mood settings
MOOD_DECAY_RATE=0.1
# Conversation settings
MAX_CONVERSATION_HISTORY=20
CONVERSATION_TIMEOUT_MINUTES=60
# -----------------------------------------------------------------------------
# COMMAND TOGGLES
# -----------------------------------------------------------------------------
COMMANDS_ENABLED=true
# CMD_RELATIONSHIP_ENABLED=true
# CMD_MOOD_ENABLED=true
# CMD_BOTSTATS_ENABLED=true
# CMD_OURHISTORY_ENABLED=true
# CMD_BIRTHDAY_ENABLED=true
# CMD_REMEMBER_ENABLED=true
# CMD_SETNAME_ENABLED=true
# CMD_WHATDOYOUKNOW_ENABLED=true
# CMD_FORGETME_ENABLED=true
# -----------------------------------------------------------------------------
# LOGGING
# -----------------------------------------------------------------------------
LOG_LEVEL=INFO
# LOG_FILE=/var/log/daemon_boyfriend.log
```
---
## Configuration Profiles
### Minimal Setup
```bash
DISCORD_TOKEN=your_token
OPENAI_API_KEY=your_key
```
### Production Setup
```bash
DISCORD_TOKEN=your_token
OPENAI_API_KEY=your_key
AI_MODEL=gpt-4o
DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/daemon
LOG_LEVEL=WARNING
```
### Development Setup
```bash
DISCORD_TOKEN=your_token
OPENAI_API_KEY=your_key
AI_MODEL=gpt-4o-mini # Cheaper model
DATABASE_ECHO=true
LOG_LEVEL=DEBUG
```
### Privacy-Focused Setup
```bash
DISCORD_TOKEN=your_token
OPENAI_API_KEY=your_key
FACT_EXTRACTION_ENABLED=false
CROSS_USER_ENABLED=false
CMD_WHATDOYOUKNOW_ENABLED=true
CMD_FORGETME_ENABLED=true
```
---
## Validation
Settings are validated at startup using Pydantic:
- **Type checking:** Ensures correct types
- **Range validation:** Enforces min/max values
- **Required fields:** Ensures essential config is present
If validation fails, the bot will not start and will show an error message indicating what's wrong.

570
docs/database.md Normal file
View File

@@ -0,0 +1,570 @@
# Database Schema Guide
This document describes the database schema used by Daemon Boyfriend.
## Table of Contents
- [Overview](#overview)
- [Core Tables](#core-tables)
- [Living AI Tables](#living-ai-tables)
- [Indexes](#indexes)
- [Relationships](#relationships)
- [Schema Diagram](#schema-diagram)
---
## Overview
### Database Support
- **Primary:** PostgreSQL with async support (`asyncpg`)
- **Fallback:** In-memory (no persistence)
- **Testing:** SQLite (via `aiosqlite`)
### Key Features
- **PortableJSON:** Custom type for PostgreSQL JSONB / SQLite JSON compatibility
- **UTC Timestamps:** All timestamps are stored as timezone-aware UTC
- **Soft Deletes:** Many tables use `is_active` flag instead of hard deletes
- **Cascade Deletes:** Foreign keys cascade on user/conversation deletion
### Connection Configuration
```bash
# PostgreSQL connection string
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/daemon_boyfriend
# Optional settings
DATABASE_ECHO=false # Log SQL queries
DATABASE_POOL_SIZE=5 # Connection pool size
DATABASE_MAX_OVERFLOW=10 # Max connections beyond pool
```
---
## Core Tables
### users
Stores Discord user information.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `discord_id` | BIGINT | Discord user ID (unique) |
| `discord_username` | VARCHAR(255) | Discord username |
| `discord_display_name` | VARCHAR(255) | Discord display name |
| `custom_name` | VARCHAR(255) | Custom name set by user/admin |
| `first_seen_at` | TIMESTAMPTZ | When user was first seen |
| `last_seen_at` | TIMESTAMPTZ | When user was last seen |
| `is_active` | BOOLEAN | Whether user is active |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Usage:**
```python
user = await user_service.get_or_create_user(
discord_id=123456789,
username="john_doe",
display_name="John"
)
print(user.display_name) # Returns custom_name or discord_display_name
```
---
### user_preferences
Key-value storage for user preferences.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `preference_key` | VARCHAR(100) | Preference name |
| `preference_value` | TEXT | Preference value |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(user_id, preference_key)`
---
### user_facts
Facts the bot knows about users.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `fact_type` | VARCHAR(50) | Type (hobby, work, family, etc.) |
| `fact_content` | TEXT | The fact itself |
| `confidence` | FLOAT | 0.0-1.0 confidence level |
| `source` | VARCHAR(50) | How learned (conversation, auto_extraction, manual) |
| `is_active` | BOOLEAN | Whether fact is active |
| `learned_at` | TIMESTAMPTZ | When fact was learned |
| `last_referenced_at` | TIMESTAMPTZ | When fact was last used |
| `category` | VARCHAR(50) | Category (same as fact_type) |
| `importance` | FLOAT | 0.0-1.0 importance level |
| `temporal_relevance` | VARCHAR(20) | past/present/future/timeless |
| `expiry_date` | TIMESTAMPTZ | When fact expires (optional) |
| `extracted_from_message_id` | BIGINT | Source Discord message ID |
| `extraction_context` | TEXT | Context of extraction |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Fact Types:**
- `hobby` - Activities, interests
- `work` - Job, career
- `family` - Family members
- `preference` - Likes, dislikes
- `location` - Places
- `event` - Life events
- `relationship` - Personal relationships
- `general` - Other facts
---
### guilds
Discord server (guild) information.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `discord_id` | BIGINT | Discord guild ID (unique) |
| `name` | VARCHAR(255) | Guild name |
| `joined_at` | TIMESTAMPTZ | When bot joined |
| `is_active` | BOOLEAN | Whether bot is active in guild |
| `settings` | JSONB | Guild-specific settings |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### guild_members
User membership in guilds.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Foreign key to guilds |
| `user_id` | BIGINT | Foreign key to users |
| `guild_nickname` | VARCHAR(255) | Nickname in this guild |
| `roles` | TEXT[] | Array of role names |
| `joined_guild_at` | TIMESTAMPTZ | When user joined guild |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(guild_id, user_id)`
---
### conversations
Conversation sessions between users and the bot.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `guild_id` | BIGINT | Guild ID (nullable for DMs) |
| `channel_id` | BIGINT | Discord channel ID |
| `started_at` | TIMESTAMPTZ | When conversation started |
| `last_message_at` | TIMESTAMPTZ | Last message timestamp |
| `message_count` | INTEGER | Number of messages |
| `is_active` | BOOLEAN | Whether conversation is active |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Conversation Timeout:**
A new conversation starts after 60 minutes of inactivity.
---
### messages
Individual messages in conversations.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `conversation_id` | BIGINT | Foreign key to conversations |
| `user_id` | BIGINT | Foreign key to users |
| `discord_message_id` | BIGINT | Discord message ID |
| `role` | VARCHAR(20) | user/assistant/system |
| `content` | TEXT | Message content |
| `has_images` | BOOLEAN | Whether message has images |
| `image_urls` | JSONB | Array of image URLs |
| `token_count` | INTEGER | Token count (estimated) |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
## Living AI Tables
### bot_states
Per-guild bot state (mood, statistics).
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Guild ID (unique, NULL = global) |
| `mood_valence` | FLOAT | -1.0 to 1.0 (sad to happy) |
| `mood_arousal` | FLOAT | -1.0 to 1.0 (calm to excited) |
| `mood_updated_at` | TIMESTAMPTZ | When mood was last updated |
| `total_messages_sent` | INTEGER | Lifetime message count |
| `total_facts_learned` | INTEGER | Facts extracted count |
| `total_users_known` | INTEGER | Unique users count |
| `first_activated_at` | TIMESTAMPTZ | Bot "birth date" |
| `preferences` | JSONB | Bot preferences |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### bot_opinions
Bot's opinions on topics.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Guild ID (NULL = global) |
| `topic` | VARCHAR(255) | Topic name (lowercase) |
| `sentiment` | FLOAT | -1.0 to 1.0 |
| `interest_level` | FLOAT | 0.0 to 1.0 |
| `discussion_count` | INTEGER | Times discussed |
| `reasoning` | TEXT | AI explanation (optional) |
| `formed_at` | TIMESTAMPTZ | When opinion formed |
| `last_reinforced_at` | TIMESTAMPTZ | When last discussed |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(guild_id, topic)`
---
### user_relationships
Relationship depth with each user.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `guild_id` | BIGINT | Guild ID (NULL = global) |
| `relationship_score` | FLOAT | 0-100 score |
| `total_interactions` | INTEGER | Total interaction count |
| `positive_interactions` | INTEGER | Positive interaction count |
| `negative_interactions` | INTEGER | Negative interaction count |
| `avg_message_length` | FLOAT | Average message length |
| `conversation_depth_avg` | FLOAT | Average conversation turns |
| `shared_references` | JSONB | Inside jokes, nicknames, etc. |
| `first_interaction_at` | TIMESTAMPTZ | First interaction |
| `last_interaction_at` | TIMESTAMPTZ | Last interaction |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(user_id, guild_id)`
**Relationship Score Levels:**
- 0-20: Stranger
- 21-40: Acquaintance
- 41-60: Friend
- 61-80: Good Friend
- 81-100: Close Friend
---
### user_communication_styles
Learned communication preferences per user.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users (unique) |
| `preferred_length` | VARCHAR(20) | short/medium/long |
| `preferred_formality` | FLOAT | 0.0 (casual) to 1.0 (formal) |
| `emoji_affinity` | FLOAT | 0.0 (none) to 1.0 (lots) |
| `humor_affinity` | FLOAT | 0.0 (serious) to 1.0 (playful) |
| `detail_preference` | FLOAT | 0.0 (concise) to 1.0 (detailed) |
| `engagement_signals` | JSONB | Engagement signal data |
| `samples_collected` | INTEGER | Number of samples analyzed |
| `confidence` | FLOAT | 0.0-1.0 confidence level |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### scheduled_events
Proactive events (birthdays, follow-ups).
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `guild_id` | BIGINT | Target guild ID |
| `channel_id` | BIGINT | Target channel ID |
| `event_type` | VARCHAR(50) | birthday/follow_up/reminder |
| `trigger_at` | TIMESTAMPTZ | When to trigger |
| `title` | VARCHAR(255) | Event title |
| `context` | JSONB | Additional context |
| `is_recurring` | BOOLEAN | Whether event recurs |
| `recurrence_rule` | VARCHAR(100) | yearly/monthly/weekly/etc. |
| `status` | VARCHAR(20) | pending/triggered/cancelled |
| `triggered_at` | TIMESTAMPTZ | When event was triggered |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### fact_associations
Cross-user fact associations.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `fact_id_1` | BIGINT | Foreign key to user_facts |
| `fact_id_2` | BIGINT | Foreign key to user_facts |
| `association_type` | VARCHAR(50) | shared_interest/same_location/etc. |
| `strength` | FLOAT | 0.0-1.0 association strength |
| `discovered_at` | TIMESTAMPTZ | When discovered |
| `created_at` | TIMESTAMPTZ | Record creation time |
**Unique Constraint:** `(fact_id_1, fact_id_2)`
---
### mood_history
Historical mood changes.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Guild ID |
| `valence` | FLOAT | Valence at this point |
| `arousal` | FLOAT | Arousal at this point |
| `trigger_type` | VARCHAR(50) | conversation/time_decay/event |
| `trigger_user_id` | BIGINT | User who triggered (optional) |
| `trigger_description` | TEXT | Description of trigger |
| `recorded_at` | TIMESTAMPTZ | When recorded |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
## Indexes
All indexes are created with `IF NOT EXISTS` for idempotency.
### Core Table Indexes
| Table | Index | Columns |
|-------|-------|---------|
| users | ix_users_discord_id | discord_id |
| users | ix_users_last_seen_at | last_seen_at |
| user_preferences | ix_user_preferences_user_id | user_id |
| user_facts | ix_user_facts_user_id | user_id |
| user_facts | ix_user_facts_fact_type | fact_type |
| user_facts | ix_user_facts_is_active | is_active |
| guilds | ix_guilds_discord_id | discord_id |
| guild_members | ix_guild_members_guild_id | guild_id |
| guild_members | ix_guild_members_user_id | user_id |
| conversations | ix_conversations_user_id | user_id |
| conversations | ix_conversations_channel_id | channel_id |
| conversations | ix_conversations_last_message_at | last_message_at |
| conversations | ix_conversations_is_active | is_active |
| messages | ix_messages_conversation_id | conversation_id |
| messages | ix_messages_user_id | user_id |
| messages | ix_messages_created_at | created_at |
### Living AI Table Indexes
| Table | Index | Columns |
|-------|-------|---------|
| bot_states | ix_bot_states_guild_id | guild_id |
| bot_opinions | ix_bot_opinions_guild_id | guild_id |
| bot_opinions | ix_bot_opinions_topic | topic |
| user_relationships | ix_user_relationships_user_id | user_id |
| user_relationships | ix_user_relationships_guild_id | guild_id |
| user_communication_styles | ix_user_communication_styles_user_id | user_id |
| scheduled_events | ix_scheduled_events_user_id | user_id |
| scheduled_events | ix_scheduled_events_trigger_at | trigger_at |
| scheduled_events | ix_scheduled_events_status | status |
| fact_associations | ix_fact_associations_fact_id_1 | fact_id_1 |
| fact_associations | ix_fact_associations_fact_id_2 | fact_id_2 |
| mood_history | ix_mood_history_guild_id | guild_id |
| mood_history | ix_mood_history_recorded_at | recorded_at |
---
## Relationships
### Entity Relationship Diagram
```
┌──────────────────┐ ┌──────────────────┐
│ users │ │ guilds │
├──────────────────┤ ├──────────────────┤
│ id (PK) │◄────┐ │ id (PK) │◄────┐
│ discord_id │ │ │ discord_id │ │
│ discord_username │ │ │ name │ │
│ custom_name │ │ │ settings │ │
└──────────────────┘ │ └──────────────────┘ │
│ │ │ │
│ │ │ │
▼ │ ▼ │
┌──────────────────┐ │ ┌──────────────────┐ │
│ user_facts │ │ │ guild_members │ │
├──────────────────┤ │ ├──────────────────┤ │
│ id (PK) │ │ │ id (PK) │ │
│ user_id (FK) ────┼─────┘ │ guild_id (FK) ───┼─────┘
│ fact_type │ │ user_id (FK) ────┼─────┐
│ fact_content │ │ guild_nickname │ │
└──────────────────┘ └──────────────────┘ │
▲ │
│ │
│ │
┌────────┴─────────┐ ┌──────────────────┐ │
│fact_associations │ │ conversations │ │
├──────────────────┤ ├──────────────────┤ │
│ id (PK) │ │ id (PK) │◄────┤
│ fact_id_1 (FK) │ │ user_id (FK) ────┼─────┘
│ fact_id_2 (FK) │ │ guild_id │
│ association_type │ │ channel_id │
└──────────────────┘ └──────────────────┘
┌──────────────────┐
│ messages │
├──────────────────┤
│ id (PK) │
│ conversation_id │
│ user_id (FK) │
│ role │
│ content │
└──────────────────┘
```
### Living AI Relationships
```
┌──────────────────┐
│ users │
├──────────────────┤
│ id (PK) │◄─────────────────────────────────────┐
└──────────────────┘ │
│ │
│ │
┌────┴────────┬───────────────┬───────────────┐ │
│ │ │ │ │
▼ ▼ ▼ ▼ │
┌────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│user_ │ │user_comm │ │scheduled │ │mood_ │ │
│relation│ │styles │ │events │ │history │ │
│ships │ │ │ │ │ │ │ │
├────────┤ ├────────────┤ ├────────────┤ ├────────────┤ │
│user_id │ │user_id │ │user_id │ │trigger_ │ │
│guild_id│ │preferred_ │ │guild_id │ │user_id ────┼──┘
│score │ │length │ │event_type │ │valence │
│ │ │emoji │ │trigger_at │ │arousal │
└────────┘ └────────────┘ └────────────┘ └────────────┘
┌──────────────────┐ ┌──────────────────┐
│ bot_states │ │ bot_opinions │
├──────────────────┤ ├──────────────────┤
│ guild_id (unique)│ │ guild_id │
│ mood_valence │ │ topic │
│ mood_arousal │ │ sentiment │
│ total_messages │ │ interest_level │
│ total_facts │ │ discussion_count │
└──────────────────┘ └──────────────────┘
```
---
## Schema Initialization
### From Code
```python
from daemon_boyfriend.services.database import db
# Initialize connection
await db.init()
# Create tables from schema.sql
await db.create_tables()
```
### From SQL
```bash
# Create database
createdb daemon_boyfriend
# Run schema
psql -U postgres -d daemon_boyfriend -f schema.sql
```
### Docker
The `docker-compose.yml` handles database setup automatically:
```bash
docker-compose up -d
```
---
## Migrations
The project uses **Alembic** for migrations, but currently relies on `schema.sql` for initial setup.
```bash
# Generate migration
alembic revision --autogenerate -m "description"
# Run migrations
alembic upgrade head
# Rollback
alembic downgrade -1
```
---
## Type Compatibility
### PortableJSON
For JSONB (PostgreSQL) and JSON (SQLite) compatibility:
```python
from daemon_boyfriend.models.base import PortableJSON
class MyModel(Base):
settings = Column(PortableJSON, default={})
```
### ensure_utc()
Handles timezone-naive datetimes from SQLite:
```python
from daemon_boyfriend.models.base import ensure_utc
# Safe for both PostgreSQL (already UTC) and SQLite (naive)
utc_time = ensure_utc(model.created_at)
```

589
docs/guides/README.md Normal file
View File

@@ -0,0 +1,589 @@
# Developer Guides
Practical guides for extending and working with the Daemon Boyfriend codebase.
## Table of Contents
- [Getting Started](#getting-started)
- [Adding a New AI Provider](#adding-a-new-ai-provider)
- [Adding a New Command](#adding-a-new-command)
- [Adding a Living AI Feature](#adding-a-living-ai-feature)
- [Testing](#testing)
- [Deployment](#deployment)
---
## Getting Started
### Prerequisites
- Python 3.11+
- PostgreSQL (optional, for persistence)
- Discord bot token
- AI provider API key
### Installation
```bash
# Clone repository
git clone <repository-url>
cd daemon-boyfriend
# Create virtual environment
python -m venv venv
source venv/bin/activate # Linux/Mac
# or: venv\Scripts\activate # Windows
# Install dependencies
pip install -r requirements.txt
# Install in development mode
pip install -e ".[dev]"
```
### Configuration
```bash
# Copy example config
cp .env.example .env
# Edit with your credentials
nano .env
```
Minimum required:
```bash
DISCORD_TOKEN=your_token
OPENAI_API_KEY=your_key
```
### Running
```bash
# Run the bot
python -m daemon_boyfriend
# Or with Docker
docker-compose up -d
```
---
## Adding a New AI Provider
### Step 1: Create Provider Class
Create `src/daemon_boyfriend/services/providers/new_provider.py`:
```python
"""New Provider implementation."""
import logging
from typing import Any
from .base import AIProvider, AIResponse, Message
logger = logging.getLogger(__name__)
class NewProvider(AIProvider):
"""Implementation for New Provider API."""
def __init__(self, api_key: str, model: str) -> None:
self._api_key = api_key
self._model = model
# Initialize client
self._client = NewProviderClient(api_key=api_key)
@property
def provider_name(self) -> str:
return "new_provider"
async def generate(
self,
messages: list[Message],
system_prompt: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.7,
) -> AIResponse:
"""Generate a response from the model."""
# Convert messages to provider format
formatted_messages = []
if system_prompt:
formatted_messages.append({
"role": "system",
"content": system_prompt
})
for msg in messages:
formatted_messages.append({
"role": msg.role,
"content": msg.content
})
# Call provider API
response = await self._client.chat.completions.create(
model=self._model,
messages=formatted_messages,
max_tokens=max_tokens,
temperature=temperature,
)
return AIResponse(
content=response.choices[0].message.content,
model=response.model,
usage={
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
)
```
### Step 2: Export from providers/__init__.py
```python
# In services/providers/__init__.py
from .new_provider import NewProvider
__all__ = [
# ... existing exports
"NewProvider",
]
```
### Step 3: Register in AIService
```python
# In services/ai_service.py
from .providers import NewProvider
class AIService:
def _create_provider(self, provider_type, api_key, model):
providers = {
"openai": OpenAIProvider,
"openrouter": OpenRouterProvider,
"anthropic": AnthropicProvider,
"gemini": GeminiProvider,
"new_provider": NewProvider, # Add here
}
# ...
```
### Step 4: Add Configuration
```python
# In config.py
class Settings(BaseSettings):
ai_provider: Literal["openai", "openrouter", "anthropic", "gemini", "new_provider"] = Field(
"openai", description="Which AI provider to use"
)
new_provider_api_key: str | None = Field(None, description="New Provider API key")
def get_api_key(self) -> str:
key_map = {
# ...existing...
"new_provider": self.new_provider_api_key,
}
```
### Step 5: Add Tests
```python
# In tests/test_providers.py
import pytest
from daemon_boyfriend.services.providers import NewProvider
@pytest.mark.asyncio
async def test_new_provider():
provider = NewProvider(api_key="test", model="test-model")
assert provider.provider_name == "new_provider"
```
---
## Adding a New Command
### Step 1: Add to Existing Cog
For simple commands, add to an existing cog:
```python
# In cogs/memory.py
@commands.command(name="newcommand")
async def new_command(self, ctx: commands.Context, *, argument: str):
"""Description of what the command does."""
if not settings.cmd_newcommand_enabled:
return
async with db.session() as session:
user_service = UserService(session)
user = await user_service.get_or_create_user(
discord_id=ctx.author.id,
username=ctx.author.name,
display_name=ctx.author.display_name,
)
# Do something with the command
result = await self._process_command(user, argument)
await ctx.send(f"Result: {result}")
```
### Step 2: Create a New Cog
For complex features, create a new cog:
```python
# In cogs/new_feature.py
"""New Feature Cog."""
import logging
import discord
from discord.ext import commands
from daemon_boyfriend.config import settings
from daemon_boyfriend.services.database import db
logger = logging.getLogger(__name__)
class NewFeatureCog(commands.Cog):
"""Commands for the new feature."""
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@commands.command(name="newfeature")
async def new_feature(self, ctx: commands.Context, *, arg: str):
"""New feature command."""
if not settings.commands_enabled:
return
# Command implementation
await ctx.send(f"New feature: {arg}")
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
"""Optional: Listen to all messages."""
if message.author.bot:
return
# Process messages if needed
async def setup(bot: commands.Bot) -> None:
"""Load the cog."""
await bot.add_cog(NewFeatureCog(bot))
```
Cogs are auto-loaded from the `cogs/` directory.
### Step 3: Add Configuration Toggle
```python
# In config.py
class Settings(BaseSettings):
cmd_newfeature_enabled: bool = Field(True, description="Enable !newfeature command")
```
---
## Adding a Living AI Feature
### Step 1: Create the Service
```python
# In services/new_feature_service.py
"""New Feature Service - description."""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from daemon_boyfriend.models import User
logger = logging.getLogger(__name__)
class NewFeatureService:
"""Manages the new feature."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_feature_data(self, user: User) -> dict:
"""Get feature data for a user."""
# Implementation
return {}
async def update_feature(self, user: User, data: dict) -> None:
"""Update feature data."""
# Implementation
pass
def get_prompt_modifier(self, data: dict) -> str:
"""Generate prompt text for this feature."""
if not data:
return ""
return f"[New Feature Context]\n{data}"
```
### Step 2: Create the Model (if needed)
```python
# In models/living_ai.py (or new file)
class NewFeatureData(Base):
"""New feature data storage."""
__tablename__ = "new_feature_data"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
guild_id = Column(BigInteger, nullable=True)
# Feature-specific fields
some_value = Column(Float, default=0.0)
some_dict = Column(PortableJSON, default={})
created_at = Column(DateTime(timezone=True), default=utc_now)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
# Relationships
user = relationship("User", back_populates="new_feature_data")
```
### Step 3: Add to Schema
```sql
-- In schema.sql
CREATE TABLE IF NOT EXISTS new_feature_data (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
guild_id BIGINT,
some_value FLOAT DEFAULT 0.0,
some_dict JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, guild_id)
);
CREATE INDEX IF NOT EXISTS ix_new_feature_data_user_id ON new_feature_data(user_id);
```
### Step 4: Add Configuration
```python
# In config.py
class Settings(BaseSettings):
new_feature_enabled: bool = Field(True, description="Enable new feature")
```
### Step 5: Integrate in AIChatCog
```python
# In cogs/ai_chat.py
from daemon_boyfriend.services.new_feature_service import NewFeatureService
class AIChatCog(commands.Cog):
async def _build_enhanced_prompt(self, ...):
# ... existing code ...
# Add new feature
if settings.new_feature_enabled:
new_feature_service = NewFeatureService(session)
feature_data = await new_feature_service.get_feature_data(user)
feature_modifier = new_feature_service.get_prompt_modifier(feature_data)
modifiers.append(feature_modifier)
```
---
## Testing
### Running Tests
```bash
# Install dev dependencies
pip install -e ".[dev]"
# Run all tests
python -m pytest tests/ -v
# Run 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
```
### Writing Tests
```python
# In tests/test_new_feature.py
import pytest
from daemon_boyfriend.services.new_feature_service import NewFeatureService
class TestNewFeatureService:
"""Tests for NewFeatureService."""
@pytest.mark.asyncio
async def test_get_feature_data(self, db_session, mock_user):
"""Test getting feature data."""
service = NewFeatureService(db_session)
data = await service.get_feature_data(mock_user)
assert data is not None
assert isinstance(data, dict)
@pytest.mark.asyncio
async def test_update_feature(self, db_session, mock_user):
"""Test updating feature data."""
service = NewFeatureService(db_session)
await service.update_feature(mock_user, {"key": "value"})
data = await service.get_feature_data(mock_user)
assert data.get("key") == "value"
```
### Using Fixtures
```python
# In tests/conftest.py
@pytest.fixture
async def db_session():
"""Provide a database session for testing."""
# Uses SQLite in-memory for tests
async with get_test_session() as session:
yield session
@pytest.fixture
def mock_user(db_session):
"""Provide a mock user for testing."""
user = User(
discord_id=123456789,
discord_username="test_user",
discord_display_name="Test User"
)
db_session.add(user)
return user
```
---
## Deployment
### Docker
```bash
# Build and run
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose down
```
### Manual
```bash
# Install
pip install -r requirements.txt
# Run with process manager (e.g., systemd)
# Create /etc/systemd/system/daemon-boyfriend.service:
[Unit]
Description=Daemon Boyfriend Discord Bot
After=network.target postgresql.service
[Service]
Type=simple
User=daemon
WorkingDirectory=/opt/daemon-boyfriend
Environment="PATH=/opt/daemon-boyfriend/venv/bin"
EnvironmentFile=/opt/daemon-boyfriend/.env
ExecStart=/opt/daemon-boyfriend/venv/bin/python -m daemon_boyfriend
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
```bash
# Enable and start
sudo systemctl enable daemon-boyfriend
sudo systemctl start daemon-boyfriend
```
### Database Setup
```bash
# Create database
sudo -u postgres createdb daemon_boyfriend
# Run schema
psql -U postgres -d daemon_boyfriend -f schema.sql
# Or let the bot create tables
# (tables are created automatically on first run)
```
---
## Best Practices
### Code Style
- Use type hints everywhere
- Follow PEP 8
- Use async/await for all I/O
- Log appropriately (debug for routine, info for significant events)
### Database
- Always use async sessions
- Use transactions appropriately
- Index frequently queried columns
- Use soft deletes where appropriate
### Testing
- Test both happy path and error cases
- Use fixtures for common setup
- Mock external services (AI providers, Discord)
- Test async code with `@pytest.mark.asyncio`
### Security
- Never log sensitive data (tokens, passwords)
- Validate user input
- Use parameterized queries (SQLAlchemy handles this)
- Rate limit where appropriate

301
docs/living-ai/README.md Normal file
View File

@@ -0,0 +1,301 @@
# Living AI System
The Living AI system gives the bot personality, emotional depth, and relationship awareness. It transforms a simple chatbot into a character that learns, remembers, and evolves through interactions.
## Table of Contents
- [Overview](#overview)
- [System Components](#system-components)
- [How It Works Together](#how-it-works-together)
- [Feature Toggle Reference](#feature-toggle-reference)
## Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Living AI System │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Mood │ │ Relationship │ │ Fact │ │ Opinion │ │
│ │ System │ │ System │ │ Extraction │ │ System │ │
│ │ │ │ │ │ │ │ │ │
│ │ Valence + │ │ 5 levels │ │ AI-based │ │ Topic │ │
│ │ Arousal │ │ 0-100 score │ │ learning │ │ sentiments │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │Communication │ │ Proactive │ │ Association │ │ Self │ │
│ │ Style │ │ Events │ │ System │ │ Awareness │ │
│ │ │ │ │ │ │ │ │ │
│ │ Learned │ │ Birthdays, │ │ Cross-user │ │ Stats, │ │
│ │ preferences │ │ follow-ups │ │ memory │ │ reflection │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## System Components
### 1. Mood System
**File:** `services/mood_service.py`
**Documentation:** [mood-system.md](mood-system.md)
The bot has emotions that affect how it responds. Uses a valence-arousal psychological model:
- **Valence** (-1 to +1): Negative to positive emotional state
- **Arousal** (-1 to +1): Calm to excited energy level
**Mood Labels:**
| Valence | Arousal | Label |
|---------|---------|-------|
| High | High | Excited |
| High | Low | Happy |
| Neutral | Low | Calm |
| Neutral | Neutral | Neutral |
| Low | Low | Bored |
| Low | High | Annoyed |
| Neutral | High | Curious |
**Key Features:**
- Time decay: Mood gradually returns to neutral
- Inertia: Changes are dampened (30% absorption rate)
- Mood affects response style via prompt modifiers
---
### 2. Relationship System
**File:** `services/relationship_service.py`
**Documentation:** [relationship-system.md](relationship-system.md)
Tracks relationship depth with each user:
| Score | Level | Behavior |
|-------|-------|----------|
| 0-20 | Stranger | Polite, formal |
| 21-40 | Acquaintance | Friendly, reserved |
| 41-60 | Friend | Casual, warm |
| 61-80 | Good Friend | Personal, references past |
| 81-100 | Close Friend | Very casual, inside jokes |
**Relationship Score Factors:**
- Interaction sentiment (+/- 0.5 base)
- Message length (up to +0.3 bonus)
- Conversation depth (up to +0.2 bonus)
- Minimum interaction bonus (+0.1)
---
### 3. Fact Extraction System
**File:** `services/fact_extraction_service.py`
**Documentation:** [fact-extraction.md](fact-extraction.md)
Autonomously learns facts about users from conversations:
**Fact Types:**
- `hobby` - Activities and interests
- `work` - Job, career, professional life
- `family` - Family members and relationships
- `preference` - Likes, dislikes, preferences
- `location` - Where they live, travel to
- `event` - Important life events
- `relationship` - Personal relationships
- `general` - Other facts
**Extraction Process:**
1. Rate-limited (default 30% of messages)
2. AI analyzes message for extractable facts
3. Deduplication against existing facts
4. Facts stored with confidence and importance scores
---
### 4. Opinion System
**File:** `services/opinion_service.py`
**Documentation:** [opinion-system.md](opinion-system.md)
Bot develops opinions on topics over time:
**Opinion Attributes:**
- **Sentiment** (-1 to +1): How positive/negative about the topic
- **Interest Level** (0 to 1): How engaged when discussing
- **Discussion Count**: How often topic has come up
- **Reasoning**: AI-generated explanation (optional)
**Topic Detection:**
Simple keyword-based extraction for categories like:
- Hobbies (gaming, music, movies, etc.)
- Technology (programming, AI, etc.)
- Life (work, family, health, etc.)
- Interests (philosophy, science, nature, etc.)
---
### 5. Communication Style System
**File:** `services/communication_style_service.py`
Learns each user's preferred communication style:
**Tracked Preferences:**
- **Response Length**: short / medium / long
- **Formality**: 0 (casual) to 1 (formal)
- **Emoji Usage**: 0 (none) to 1 (frequent)
- **Humor Level**: 0 (serious) to 1 (playful)
- **Detail Level**: 0 (brief) to 1 (thorough)
**Learning Process:**
- Analyzes last 50 messages (rolling window)
- Requires 10+ samples for confidence > 0.3
- Generates prompt modifiers to adapt response style
---
### 6. Proactive Events System
**File:** `services/proactive_service.py`
Schedules and triggers proactive messages:
**Event Types:**
- **Birthday**: Remembers and celebrates birthdays
- **Follow-up**: Returns to check on mentioned events
- **Reminder**: General scheduled reminders
**Detection Methods:**
- Birthday: Regex patterns for dates
- Follow-up: AI-based event detection or keyword matching
**Event Lifecycle:**
1. Detection from conversation
2. Scheduled in database
3. Triggered when due
4. Personalized message generated
---
### 7. Association System
**File:** `services/association_service.py`
Links facts across different users (optional, disabled by default):
**Use Cases:**
- "User A and User B both work at the same company"
- "Multiple users share an interest in hiking"
- Enables group context and shared topic suggestions
---
### 8. Self-Awareness System
**File:** `services/self_awareness_service.py`
Provides the bot with statistics about itself:
**Available Stats:**
- Age (time since first activation)
- Total messages sent
- Total facts learned
- Total users known
- Favorite topics (from opinions)
Used for the `!botstats` command and self-reflection in responses.
---
## How It Works Together
When a user sends a message, the Living AI components work together:
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ Message Processing │
└──────────────────────────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐┌──────────────┐┌──────────────┐
│ Get Mood ││Get Relationship│ Get Style │
│ ││ ││ │
│ Current state││ Level + refs ││ Preferences │
└──────────────┘└──────────────┘└──────────────┘
│ │ │
└───────────────┼───────────────┘
┌──────────────────────────────────────┐
│ Build Enhanced System Prompt │
│ │
│ Base personality + mood modifier + │
│ relationship context + style prefs + │
│ relevant opinions │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Generate Response │
└──────────────────────────────────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐┌──────────────┐┌──────────────┐
│ Update Mood ││ Record ││ Maybe │
│ ││ Interaction ││Extract Facts │
│Sentiment + ││ ││ │
│arousal delta ││Score update ││ Rate-limited │
└──────────────┘└──────────────┘└──────────────┘
┌──────────────────────────────────────┐
│ Check for Proactive Events │
│ │
│ Detect birthdays, follow-ups │
└──────────────────────────────────────┘
```
### Example System Prompt Enhancement
```
[Base Personality]
You are Daemon, a friendly AI companion...
[Mood Modifier]
You're feeling enthusiastic and energetic right now! Be expressive,
use exclamation marks, show genuine excitement.
[Relationship Context]
This is a good friend you know well. Be relaxed and personal.
Reference things you've talked about before. Feel free to be playful.
You have inside jokes together: "the coffee incident".
[Communication Style]
This user prefers longer, detailed responses with some humor.
They use casual language, so match their tone.
[Relevant Opinions]
You really enjoy discussing programming; You find gaming interesting.
```
---
## Feature Toggle Reference
All Living AI features can be individually enabled/disabled:
| Environment Variable | Default | Description |
|---------------------|---------|-------------|
| `LIVING_AI_ENABLED` | `true` | Master switch for all Living AI features |
| `MOOD_ENABLED` | `true` | Enable mood system |
| `RELATIONSHIP_ENABLED` | `true` | Enable relationship tracking |
| `FACT_EXTRACTION_ENABLED` | `true` | Enable autonomous fact extraction |
| `FACT_EXTRACTION_RATE` | `0.3` | Probability of extracting facts (0-1) |
| `PROACTIVE_ENABLED` | `true` | Enable proactive messages |
| `CROSS_USER_ENABLED` | `false` | Enable cross-user associations |
| `OPINION_FORMATION_ENABLED` | `true` | Enable opinion formation |
| `STYLE_LEARNING_ENABLED` | `true` | Enable communication style learning |
| `MOOD_DECAY_RATE` | `0.1` | How fast mood returns to neutral (per hour) |
---
## Detailed Documentation
- [Mood System Deep Dive](mood-system.md)
- [Relationship System Deep Dive](relationship-system.md)
- [Fact Extraction Deep Dive](fact-extraction.md)
- [Opinion System Deep Dive](opinion-system.md)

View File

@@ -0,0 +1,441 @@
# Fact Extraction System Deep Dive
The fact extraction system autonomously learns facts about users from their conversations with the bot.
## Overview
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ Fact Extraction Pipeline │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Rate Limiter (30%) │
│ Only process ~30% of messages │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Extractability Check │
│ - Min 20 chars │
│ - Not a command │
│ - Not just greetings │
│ - Has enough text content │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ AI Fact Extraction │
│ Extracts structured facts │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Deduplication │
│ - Exact match check │
│ - Substring check │
│ - Word overlap check (70%) │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Validation & Storage │
│ Save valid, unique facts │
└──────────────────────────────────────┘
```
---
## Fact Types
| Type | Description | Examples |
|------|-------------|----------|
| `hobby` | Activities, interests, pastimes | "loves hiking", "plays guitar" |
| `work` | Job, career, professional life | "works as a software engineer at Google" |
| `family` | Family members, relationships | "has two younger sisters" |
| `preference` | Likes, dislikes, preferences | "prefers dark roast coffee" |
| `location` | Places they live, visit, are from | "lives in Amsterdam" |
| `event` | Important life events | "recently got married" |
| `relationship` | Personal relationships | "has a girlfriend named Sarah" |
| `general` | Other facts that don't fit | "speaks three languages" |
---
## Fact Attributes
Each extracted fact has:
| Attribute | Type | Description |
|-----------|------|-------------|
| `type` | string | One of the fact types above |
| `content` | string | The fact itself (third person) |
| `confidence` | float | How certain the extraction is |
| `importance` | float | How significant the fact is |
| `temporal` | string | Time relevance |
### Confidence Levels
| Level | Value | When to Use |
|-------|-------|-------------|
| Implied | 0.6 | Fact is suggested but not stated |
| Stated | 0.8 | Fact is clearly mentioned |
| Explicit | 1.0 | User directly stated the fact |
### Importance Levels
| Level | Value | Description |
|-------|-------|-------------|
| Trivial | 0.3 | Minor detail |
| Normal | 0.5 | Standard fact |
| Significant | 0.8 | Important information |
| Very Important | 1.0 | Major life fact |
### Temporal Relevance
| Value | Description | Example |
|-------|-------------|---------|
| `past` | Happened before | "used to live in Paris" |
| `present` | Currently true | "works at Microsoft" |
| `future` | Planned/expected | "getting married next month" |
| `timeless` | Always true | "was born in Japan" |
---
## Rate Limiting
To prevent excessive API calls and ensure quality:
```python
# Only attempt extraction on ~30% of messages
if random.random() > settings.fact_extraction_rate:
return [] # Skip this message
```
**Configuration:**
- `FACT_EXTRACTION_RATE` = 0.3 (default)
- Can be adjusted from 0.0 (disabled) to 1.0 (every message)
**Why Rate Limit?**
- Reduces AI API costs
- Not every message contains facts
- Prevents redundant extractions
- Spreads learning over time
---
## Extractability Checks
Before sending to AI, messages are filtered:
### Minimum Length
```python
MIN_MESSAGE_LENGTH = 20
if len(content) < MIN_MESSAGE_LENGTH:
return False
```
### Alpha Ratio
```python
# Must be at least 50% alphabetic characters
alpha_ratio = sum(c.isalpha() for c in content) / len(content)
if alpha_ratio < 0.5:
return False
```
### Command Detection
```python
# Skip command-like messages
if content.startswith(("!", "/", "?", ".")):
return False
```
### Short Phrase Filter
```python
short_phrases = [
"hi", "hello", "hey", "yo", "sup", "bye", "goodbye",
"thanks", "thank you", "ok", "okay", "yes", "no",
"yeah", "nah", "lol", "lmao", "haha", "hehe",
"nice", "cool", "wow"
]
if content.lower().strip() in short_phrases:
return False
```
---
## AI Extraction Prompt
The system sends a carefully crafted prompt to the AI:
```
You are a fact extraction assistant. Extract factual information
about the user from their message.
ALREADY KNOWN FACTS:
- [hobby] loves hiking
- [work] works as senior engineer at Google
RULES:
1. Only extract CONCRETE facts, not opinions or transient states
2. Skip if the fact is already known (listed above)
3. Skip greetings, questions, or meta-conversation
4. Skip vague statements like "I like stuff" - be specific
5. Focus on: hobbies, work, family, preferences, locations, events, relationships
6. Keep fact content concise (under 100 characters)
7. Maximum 3 facts per message
OUTPUT FORMAT:
Return a JSON array of facts, or empty array [] if no extractable facts.
```
### Example Input/Output
**Input:** "I just got promoted to senior engineer at Google last week!"
**Output:**
```json
[
{
"type": "work",
"content": "works as senior engineer at Google",
"confidence": 1.0,
"importance": 0.8,
"temporal": "present"
},
{
"type": "event",
"content": "recently got promoted",
"confidence": 1.0,
"importance": 0.7,
"temporal": "past"
}
]
```
**Input:** "hey what's up"
**Output:**
```json
[]
```
---
## Deduplication
Before saving, facts are checked for duplicates:
### 1. Exact Match
```python
if new_content.lower() in existing_content:
return True # Is duplicate
```
### 2. Substring Check
```python
# If one contains the other (for facts > 10 chars)
if len(new_lower) > 10 and len(existing) > 10:
if new_lower in existing or existing in new_lower:
return True
```
### 3. Word Overlap (70% threshold)
```python
new_words = set(new_lower.split())
existing_words = set(existing.split())
if len(new_words) > 2 and len(existing_words) > 2:
overlap = len(new_words & existing_words)
min_len = min(len(new_words), len(existing_words))
if overlap / min_len > 0.7:
return True
```
**Examples:**
- "loves hiking" vs "loves hiking" → **Duplicate** (exact)
- "works as engineer at Google" vs "engineer at Google" → **Duplicate** (substring)
- "has two younger sisters" vs "has two younger brothers" → **Duplicate** (70% overlap)
- "loves hiking" vs "enjoys cooking" → **Not duplicate**
---
## Database Schema
### UserFact Table
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `user_id` | Integer | Foreign key to users |
| `fact_type` | String | Category (hobby, work, etc.) |
| `fact_content` | String | The fact content |
| `confidence` | Float | Extraction confidence (0-1) |
| `source` | String | "auto_extraction" or "manual" |
| `is_active` | Boolean | Whether fact is still valid |
| `learned_at` | DateTime | When fact was learned |
| `category` | String | Same as fact_type |
| `importance` | Float | Importance level (0-1) |
| `temporal_relevance` | String | past/present/future/timeless |
| `extracted_from_message_id` | BigInteger | Discord message ID |
| `extraction_context` | String | First 200 chars of source message |
---
## API Reference
### FactExtractionService
```python
class FactExtractionService:
MIN_MESSAGE_LENGTH = 20
MAX_FACTS_PER_MESSAGE = 3
def __init__(
self,
session: AsyncSession,
ai_service=None
)
async def maybe_extract_facts(
self,
user: User,
message_content: str,
discord_message_id: int | None = None,
) -> list[UserFact]
# Rate-limited extraction
async def extract_facts(
self,
user: User,
message_content: str,
discord_message_id: int | None = None,
) -> list[UserFact]
# Direct extraction (no rate limiting)
```
---
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `FACT_EXTRACTION_ENABLED` | `true` | Enable/disable fact extraction |
| `FACT_EXTRACTION_RATE` | `0.3` | Probability of extraction (0-1) |
---
## Example Usage
```python
from daemon_boyfriend.services.fact_extraction_service import FactExtractionService
async with get_session() as session:
fact_service = FactExtractionService(session, ai_service)
# Rate-limited extraction (recommended for normal use)
new_facts = await fact_service.maybe_extract_facts(
user=user,
message_content="I just started learning Japanese!",
discord_message_id=123456789
)
for fact in new_facts:
print(f"Learned: [{fact.fact_type}] {fact.fact_content}")
# Direct extraction (skips rate limiting)
facts = await fact_service.extract_facts(
user=user,
message_content="I work at Microsoft as a PM"
)
await session.commit()
```
---
## Manual Fact Addition
Users can also add facts manually:
### !remember Command
```
User: !remember I'm allergic to peanuts
Bot: Got it! I'll remember that you're allergic to peanuts.
```
These facts have:
- `source = "manual"` instead of `"auto_extraction"`
- `confidence = 1.0` (user stated directly)
- `importance = 0.8` (user wanted it remembered)
### Admin Command
```
Admin: !teachbot @user Works night shifts
Bot: Got it! I've noted that about @user.
```
---
## Fact Retrieval
Facts are used in AI prompts for context:
```python
# Build user context including facts
async def build_user_context(user: User) -> str:
facts = await get_active_facts(user)
context = f"User: {user.custom_name or user.discord_name}\n"
context += "Known facts:\n"
for fact in facts:
context += f"- {fact.fact_content}\n"
return context
```
### Example Context
```
User: Alex
Known facts:
- works as senior engineer at Google
- loves hiking on weekends
- has two cats named Luna and Stella
- prefers dark roast coffee
- speaks English and Japanese
```
---
## Design Considerations
### Why Third Person?
Facts are stored in third person ("loves hiking" not "I love hiking"):
- Easier to inject into prompts
- Consistent format
- Works in any context
### Why Rate Limit?
- Not every message contains facts
- AI API calls are expensive
- Quality over quantity
- Natural learning pace
### Why Deduplication?
- Prevents redundant storage
- Keeps fact list clean
- Reduces noise in prompts
- Respects user privacy (one fact = one entry)
### Privacy Considerations
- Facts can be viewed with `!whatdoyouknow`
- Facts can be deleted with `!forgetme`
- Extraction context is stored (can be audited)
- Source message ID is stored (for reference)

View File

@@ -0,0 +1,338 @@
# Mood System Deep Dive
The mood system gives the bot emotional states that evolve over time and affect how it responds to users.
## Psychological Model
The mood system uses the **Valence-Arousal Model** from affective psychology:
```
High Arousal (+1)
Annoyed │ Excited
● │ ●
Curious │
● │
Low Valence ────────────────┼──────────────── High Valence
(-1) │ (+1)
Bored │ Happy
● │ ●
Calm │
● │
Low Arousal (-1)
```
### Dimensions
**Valence** (-1 to +1)
- Represents the positive/negative quality of the emotional state
- -1 = Very negative (sad, frustrated, upset)
- 0 = Neutral
- +1 = Very positive (happy, joyful, content)
**Arousal** (-1 to +1)
- Represents the energy level or activation
- -1 = Very low energy (calm, sleepy, relaxed)
- 0 = Neutral energy
- +1 = Very high energy (excited, alert, agitated)
---
## Mood Labels
The system classifies the current mood into seven labels:
| Label | Valence | Arousal | Description |
|-------|---------|---------|-------------|
| **Excited** | > 0.3 | > 0.3 | High energy, positive emotions |
| **Happy** | > 0.3 | ≤ 0.3 | Positive but calm contentment |
| **Calm** | -0.3 to 0.3 | < -0.3 | Peaceful, serene state |
| **Neutral** | -0.3 to 0.3 | -0.3 to 0.3 | Baseline, unremarkable state |
| **Bored** | < -0.3 | ≤ 0.3 | Low engagement, understimulated |
| **Annoyed** | < -0.3 | > 0.3 | Frustrated, irritated |
| **Curious** | -0.3 to 0.3 | > 0.3 | Interested, engaged, questioning |
### Classification Logic
```python
def _classify_mood(valence: float, arousal: float) -> MoodLabel:
if valence > 0.3:
return MoodLabel.EXCITED if arousal > 0.3 else MoodLabel.HAPPY
elif valence < -0.3:
return MoodLabel.ANNOYED if arousal > 0.3 else MoodLabel.BORED
else:
if arousal > 0.3:
return MoodLabel.CURIOUS
elif arousal < -0.3:
return MoodLabel.CALM
return MoodLabel.NEUTRAL
```
---
## Mood Intensity
Intensity measures how strong the current mood is:
```python
intensity = (abs(valence) + abs(arousal)) / 2
```
- **0.0 - 0.2**: Very weak, doesn't affect behavior
- **0.2 - 0.5**: Moderate, subtle behavioral changes
- **0.5 - 0.7**: Strong, noticeable behavioral changes
- **0.7 - 1.0**: Very strong, significant behavioral changes
---
## Time Decay
Mood naturally decays toward neutral over time:
```python
hours_since_update = (now - last_update).total_seconds() / 3600
decay_factor = max(0, 1 - (decay_rate * hours_since_update))
current_valence = stored_valence * decay_factor
current_arousal = stored_arousal * decay_factor
```
**Configuration:**
- `MOOD_DECAY_RATE` = 0.1 (default)
- After 10 hours, mood is fully neutral
**Decay Examples:**
| Hours | Decay Factor | Effect |
|-------|--------------|--------|
| 0 | 1.0 | Full mood |
| 2 | 0.8 | 80% of mood remains |
| 5 | 0.5 | 50% of mood remains |
| 10 | 0.0 | Fully neutral |
---
## Mood Updates
When an interaction occurs, mood is updated:
```python
new_valence = current_valence + (sentiment_delta * 0.3)
new_arousal = current_arousal + (engagement_delta * 0.3)
```
### Dampening (Inertia)
Changes are dampened by 70% (only 30% absorption):
- Prevents wild mood swings
- Creates emotional stability
- Makes mood feel more natural
### Update Triggers
| Trigger Type | Sentiment Source | Engagement Source |
|--------------|------------------|-------------------|
| `conversation` | Message sentiment | Message engagement |
| `event` | Event nature | Event importance |
| `time` | Scheduled | Scheduled |
### Input Parameters
**sentiment_delta** (-1 to +1)
- Positive: Happy interactions, compliments, fun conversations
- Negative: Arguments, frustration, rude messages
- Derived from AI analysis or keyword detection
**engagement_delta** (-1 to +1)
- Positive: Long conversations, interesting topics, active engagement
- Negative: Short dismissive messages, ignored responses
- Derived from message length, conversation turns, topic interest
---
## Prompt Modifiers
Based on current mood, the system generates prompt text:
### Excited (High valence, High arousal)
```
You're feeling enthusiastic and energetic right now!
Be expressive, use exclamation marks, show genuine excitement.
```
### Happy (High valence, Low arousal)
```
You're in a good mood - warm, friendly, and content.
Be positive and encouraging in your responses.
```
### Calm (Neutral valence, Low arousal)
```
You're feeling peaceful and relaxed.
Respond thoughtfully and with a serene demeanor.
```
### Neutral
```
(No modifier)
```
### Bored (Low valence, Low arousal)
```
You're feeling a bit understimulated.
Keep responses shorter, maybe try to steer toward more interesting topics.
```
### Annoyed (Low valence, High arousal)
```
You're slightly irritated.
Be a bit more terse, less patient with repetition or vague questions.
```
### Curious (Neutral valence, High arousal)
```
You're feeling inquisitive and engaged!
Ask follow-up questions, show genuine interest in what the user is saying.
```
### Intensity Prefix
For strong moods (intensity > 0.7):
```
[Strong mood] You're feeling enthusiastic...
```
---
## Mood History
All mood changes are recorded in `mood_history` table:
| Field | Description |
|-------|-------------|
| `guild_id` | Guild where mood changed |
| `valence` | New valence value |
| `arousal` | New arousal value |
| `trigger_type` | What caused the change |
| `trigger_user_id` | Who triggered it (if any) |
| `trigger_description` | Description of event |
| `recorded_at` | When change occurred |
This enables:
- Mood trend analysis
- Understanding what affects mood
- Debugging mood issues
- User impact tracking
---
## Bot Statistics
The mood service also tracks global statistics:
| Statistic | Description |
|-----------|-------------|
| `total_messages_sent` | Lifetime message count |
| `total_facts_learned` | Facts extracted from conversations |
| `total_users_known` | Unique users interacted with |
| `first_activated_at` | Bot "birth date" |
Used for self-awareness and the `!botstats` command.
---
## API Reference
### MoodService
```python
class MoodService:
def __init__(self, session: AsyncSession)
async def get_current_mood(
self,
guild_id: int | None = None
) -> MoodState
async def update_mood(
self,
guild_id: int | None,
sentiment_delta: float,
engagement_delta: float,
trigger_type: str,
trigger_user_id: int | None = None,
trigger_description: str | None = None,
) -> MoodState
async def increment_stats(
self,
guild_id: int | None,
messages_sent: int = 0,
facts_learned: int = 0,
users_known: int = 0,
) -> None
async def get_stats(
self,
guild_id: int | None = None
) -> dict
def get_mood_prompt_modifier(
self,
mood: MoodState
) -> str
```
### MoodState
```python
@dataclass
class MoodState:
valence: float # -1 to 1
arousal: float # -1 to 1
label: MoodLabel # Classified label
intensity: float # 0 to 1
```
---
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `MOOD_ENABLED` | `true` | Enable/disable mood system |
| `MOOD_DECAY_RATE` | `0.1` | Decay per hour toward neutral |
---
## Example Usage
```python
from daemon_boyfriend.services.mood_service import MoodService
async with get_session() as session:
mood_service = MoodService(session)
# Get current mood
mood = await mood_service.get_current_mood(guild_id=123)
print(f"Current mood: {mood.label.value} (intensity: {mood.intensity})")
# Update mood after interaction
new_mood = await mood_service.update_mood(
guild_id=123,
sentiment_delta=0.5, # Positive interaction
engagement_delta=0.3, # Moderately engaging
trigger_type="conversation",
trigger_user_id=456,
trigger_description="User shared good news"
)
# Get prompt modifier
modifier = mood_service.get_mood_prompt_modifier(new_mood)
print(f"Prompt modifier: {modifier}")
await session.commit()
```

View File

@@ -0,0 +1,418 @@
# Opinion System Deep Dive
The opinion system allows the bot to develop and express opinions on topics it discusses with users.
## Overview
The bot forms opinions through repeated discussions:
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ Opinion Formation │
└──────────────────────────────────────────────────────────────────────────────┘
Discussion 1: "I love gaming!" → gaming: sentiment +0.8
Discussion 2: "Games are so fun!" → gaming: sentiment +0.7 (weighted avg)
Discussion 3: "I beat the boss!" → gaming: sentiment +0.6, interest +0.8
After 3+ discussions → Opinion formed!
"You really enjoy discussing gaming"
```
---
## Opinion Attributes
Each opinion tracks:
| Attribute | Type | Range | Description |
|-----------|------|-------|-------------|
| `topic` | string | - | The topic (lowercase) |
| `sentiment` | float | -1 to +1 | How positive/negative the bot feels |
| `interest_level` | float | 0 to 1 | How engaged/interested |
| `discussion_count` | int | 0+ | How often discussed |
| `reasoning` | string | - | AI-generated explanation (optional) |
| `last_reinforced_at` | datetime | - | When last discussed |
### Sentiment Interpretation
| Range | Interpretation | Prompt Modifier |
|-------|----------------|-----------------|
| > 0.5 | Really enjoys | "You really enjoy discussing {topic}" |
| 0.2 to 0.5 | Finds interesting | "You find {topic} interesting" |
| -0.3 to 0.2 | Neutral | (no modifier) |
| < -0.3 | Not enthusiastic | "You're not particularly enthusiastic about {topic}" |
### Interest Level Interpretation
| Range | Interpretation |
|-------|----------------|
| 0.8 - 1.0 | Very engaged when discussing |
| 0.5 - 0.8 | Moderately interested |
| 0.2 - 0.5 | Somewhat interested |
| 0.0 - 0.2 | Not very interested |
---
## Opinion Updates
When a topic is discussed, the opinion is updated using weighted averaging:
```python
weight = 0.2 # 20% weight to new data
new_sentiment = (old_sentiment * 0.8) + (discussion_sentiment * 0.2)
new_interest = (old_interest * 0.8) + (engagement_level * 0.2)
```
### Why 20% Weight?
- Prevents single interactions from dominating
- Opinions evolve gradually over time
- Reflects how real opinions form
- Protects against manipulation
### Example Evolution
```
Initial: sentiment = 0.0, interest = 0.5
Discussion 1 (sentiment=0.8, engagement=0.7):
sentiment = 0.0 * 0.8 + 0.8 * 0.2 = 0.16
interest = 0.5 * 0.8 + 0.7 * 0.2 = 0.54
Discussion 2 (sentiment=0.6, engagement=0.9):
sentiment = 0.16 * 0.8 + 0.6 * 0.2 = 0.248
interest = 0.54 * 0.8 + 0.9 * 0.2 = 0.612
Discussion 3 (sentiment=0.7, engagement=0.8):
sentiment = 0.248 * 0.8 + 0.7 * 0.2 = 0.338
interest = 0.612 * 0.8 + 0.8 * 0.2 = 0.65
After 3 discussions: sentiment=0.34, interest=0.65
→ "You find programming interesting"
```
---
## Topic Detection
Topics are extracted from messages using keyword matching:
### Topic Categories
```python
topic_keywords = {
# Hobbies
"gaming": ["game", "gaming", "video game", "play", "xbox", "playstation", ...],
"music": ["music", "song", "band", "album", "concert", "spotify", ...],
"movies": ["movie", "film", "cinema", "netflix", "show", "series", ...],
"reading": ["book", "read", "novel", "author", "library", "kindle"],
"sports": ["sports", "football", "soccer", "basketball", "gym", ...],
"cooking": ["cook", "recipe", "food", "restaurant", "meal", ...],
"travel": ["travel", "trip", "vacation", "flight", "hotel", ...],
"art": ["art", "painting", "drawing", "museum", "gallery", ...],
# Tech
"programming": ["code", "programming", "developer", "software", ...],
"technology": ["tech", "computer", "phone", "app", "website", ...],
"ai": ["ai", "artificial intelligence", "machine learning", ...],
# Life
"work": ["work", "job", "office", "career", "boss", "meeting"],
"family": ["family", "parents", "mom", "dad", "brother", "sister", ...],
"pets": ["pet", "dog", "cat", "puppy", "kitten", "animal"],
"health": ["health", "doctor", "exercise", "diet", "sleep", ...],
# Interests
"philosophy": ["philosophy", "meaning", "life", "existence", ...],
"science": ["science", "research", "study", "experiment", ...],
"nature": ["nature", "outdoor", "hiking", "camping", "mountain", ...],
}
```
### Detection Function
```python
def extract_topics_from_message(message: str) -> list[str]:
message_lower = message.lower()
found_topics = []
for topic, keywords in topic_keywords.items():
for keyword in keywords:
if keyword in message_lower:
if topic not in found_topics:
found_topics.append(topic)
break
return found_topics
```
### Example
**Message:** "I've been playing this new video game all weekend!"
**Detected Topics:** `["gaming"]`
---
## Opinion Requirements
Opinions are only considered "formed" after 3+ discussions:
```python
async def get_top_interests(guild_id, limit=5):
return select(BotOpinion).where(
BotOpinion.guild_id == guild_id,
BotOpinion.discussion_count >= 3, # Minimum threshold
).order_by(...)
```
**Why 3 discussions?**
- Single mentions don't indicate sustained interest
- Prevents volatile opinion formation
- Ensures meaningful opinions
- Reflects genuine engagement with topic
---
## Prompt Modifiers
Relevant opinions are included in the AI's system prompt:
```python
def get_opinion_prompt_modifier(opinions: list[BotOpinion]) -> str:
parts = []
for op in opinions[:3]: # Max 3 opinions
if op.sentiment > 0.5:
parts.append(f"You really enjoy discussing {op.topic}")
elif op.sentiment > 0.2:
parts.append(f"You find {op.topic} interesting")
elif op.sentiment < -0.3:
parts.append(f"You're not particularly enthusiastic about {op.topic}")
if op.reasoning:
parts.append(f"({op.reasoning})")
return "; ".join(parts)
```
### Example Output
```
You really enjoy discussing programming; You find gaming interesting;
You're not particularly enthusiastic about politics (You prefer
to focus on fun and creative topics).
```
---
## Opinion Reasoning
Optionally, AI can generate reasoning for opinions:
```python
await opinion_service.set_opinion_reasoning(
topic="programming",
guild_id=123,
reasoning="It's fascinating to help people create things"
)
```
This adds context when the opinion is mentioned in prompts.
---
## Database Schema
### BotOpinion Table
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `guild_id` | BigInteger | Guild ID (nullable for global) |
| `topic` | String | Topic name (lowercase) |
| `sentiment` | Float | -1 to +1 sentiment |
| `interest_level` | Float | 0 to 1 interest |
| `discussion_count` | Integer | Number of discussions |
| `reasoning` | String | AI explanation (optional) |
| `formed_at` | DateTime | When first discussed |
| `last_reinforced_at` | DateTime | When last discussed |
### Unique Constraint
Each `(guild_id, topic)` combination is unique.
---
## API Reference
### OpinionService
```python
class OpinionService:
def __init__(self, session: AsyncSession)
async def get_opinion(
self,
topic: str,
guild_id: int | None = None
) -> BotOpinion | None
async def get_or_create_opinion(
self,
topic: str,
guild_id: int | None = None
) -> BotOpinion
async def record_topic_discussion(
self,
topic: str,
guild_id: int | None,
sentiment: float,
engagement_level: float,
) -> BotOpinion
async def set_opinion_reasoning(
self,
topic: str,
guild_id: int | None,
reasoning: str
) -> None
async def get_top_interests(
self,
guild_id: int | None = None,
limit: int = 5
) -> list[BotOpinion]
async def get_relevant_opinions(
self,
topics: list[str],
guild_id: int | None = None
) -> list[BotOpinion]
def get_opinion_prompt_modifier(
self,
opinions: list[BotOpinion]
) -> str
async def get_all_opinions(
self,
guild_id: int | None = None
) -> list[BotOpinion]
```
### Helper Function
```python
def extract_topics_from_message(message: str) -> list[str]
```
---
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `OPINION_FORMATION_ENABLED` | `true` | Enable/disable opinion system |
---
## Example Usage
```python
from daemon_boyfriend.services.opinion_service import (
OpinionService,
extract_topics_from_message
)
async with get_session() as session:
opinion_service = OpinionService(session)
# Extract topics from message
message = "I've been coding a lot in Python lately!"
topics = extract_topics_from_message(message)
# topics = ["programming"]
# Record discussion with positive sentiment
for topic in topics:
await opinion_service.record_topic_discussion(
topic=topic,
guild_id=123,
sentiment=0.7,
engagement_level=0.8
)
# Get top interests for prompt building
interests = await opinion_service.get_top_interests(guild_id=123)
print("Bot's top interests:")
for interest in interests:
print(f" {interest.topic}: sentiment={interest.sentiment:.2f}")
# Get opinions relevant to current conversation
relevant = await opinion_service.get_relevant_opinions(
topics=["programming", "gaming"],
guild_id=123
)
modifier = opinion_service.get_opinion_prompt_modifier(relevant)
print(f"Prompt modifier: {modifier}")
await session.commit()
```
---
## Use in Conversation Flow
```
1. User sends message
2. Extract topics from message
topics = extract_topics_from_message(message)
3. Get relevant opinions
opinions = await opinion_service.get_relevant_opinions(topics, guild_id)
4. Add to system prompt
prompt += opinion_service.get_opinion_prompt_modifier(opinions)
5. Generate response
6. Record topic discussions (post-response)
for topic in topics:
await opinion_service.record_topic_discussion(
topic, guild_id, sentiment, engagement
)
```
---
## Design Considerations
### Why Per-Guild?
- Different server contexts may lead to different opinions
- Gaming server → gaming-positive opinions
- Work server → professional topic preferences
- Allows customization per community
### Why Keyword-Based Detection?
- Fast and simple
- No additional AI API calls
- Predictable behavior
- Easy to extend with more keywords
### Future Improvements
For production, consider:
- NLP-based topic extraction
- LLM topic detection for nuanced topics
- Hierarchical topic relationships
- Opinion decay over time (for less-discussed topics)

View File

@@ -0,0 +1,417 @@
# Relationship System Deep Dive
The relationship system tracks how well the bot knows each user and adjusts its behavior accordingly.
## Relationship Levels
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Relationship Progression │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 0 ──────── 20 ──────── 40 ──────── 60 ──────── 80 ──────── 100 │
│ │ │ │ │ │ │ │
│ │ Stranger │Acquaintance│ Friend │Good Friend│Close Friend│ │
│ │ │ │ │ │ │ │
│ │ Polite │ Friendly │ Casual │ Personal │Very casual │ │
│ │ Formal │ Reserved │ Warm │ References│Inside jokes│ │
│ │ │ │ │ past │ │ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Level Details
#### Stranger (Score 0-20)
**Behavior:** Polite, formal, welcoming
```
This is someone you don't know well yet.
Be polite and welcoming, but keep some professional distance.
Use more formal language.
```
- Uses formal language ("Hello", not "Hey!")
- Doesn't assume familiarity
- Introduces itself clearly
- Asks clarifying questions
#### Acquaintance (Score 21-40)
**Behavior:** Friendly, reserved
```
This is someone you've chatted with a few times.
Be friendly and warm, but still somewhat reserved.
```
- More relaxed tone
- Uses the user's name occasionally
- Shows memory of basic facts
- Still maintains some distance
#### Friend (Score 41-60)
**Behavior:** Casual, warm
```
This is a friend! Be casual and warm.
Use their name occasionally, show you remember past conversations.
```
- Natural, conversational tone
- References past conversations
- Shows genuine interest
- Comfortable with casual language
#### Good Friend (Score 61-80)
**Behavior:** Personal, references past
```
This is a good friend you know well.
Be relaxed and personal. Reference things you've talked about before.
Feel free to be playful.
```
- Very comfortable tone
- Recalls shared experiences
- May tease gently
- Shows deeper understanding
#### Close Friend (Score 81-100)
**Behavior:** Very casual, inside jokes
```
This is a close friend! Be very casual and familiar.
Use inside jokes if you have any, be supportive and genuine.
You can tease them gently and be more emotionally open.
```
Additional context for close friends:
- "You have inside jokes together: [jokes]"
- "You sometimes call them: [nickname]"
---
## Score Calculation
### Delta Formula
```python
def _calculate_score_delta(sentiment, message_length, conversation_turns):
# Base change from sentiment (-0.5 to +0.5)
base_delta = sentiment * 0.5
# Bonus for longer messages (up to +0.3)
length_bonus = min(0.3, message_length / 500)
# Bonus for deeper conversations (up to +0.2)
depth_bonus = min(0.2, conversation_turns * 0.05)
# Minimum interaction bonus (+0.1 just for talking)
interaction_bonus = 0.1
total_delta = base_delta + length_bonus + depth_bonus + interaction_bonus
# Clamp to reasonable range
return max(-1.0, min(1.0, total_delta))
```
### Score Components
| Component | Range | Description |
|-----------|-------|-------------|
| **Base (sentiment)** | -0.5 to +0.5 | Positive/negative interaction quality |
| **Length bonus** | 0 to +0.3 | Reward for longer messages |
| **Depth bonus** | 0 to +0.2 | Reward for back-and-forth |
| **Interaction bonus** | +0.1 | Reward just for interacting |
### Example Calculations
**Friendly chat (100 chars, positive sentiment):**
```
base_delta = 0.4 * 0.5 = 0.2
length_bonus = min(0.3, 100/500) = 0.2
depth_bonus = 0.05
interaction_bonus = 0.1
total = 0.55 points
```
**Short dismissive message:**
```
base_delta = -0.3 * 0.5 = -0.15
length_bonus = min(0.3, 20/500) = 0.04
depth_bonus = 0.05
interaction_bonus = 0.1
total = 0.04 points (still positive due to interaction bonus!)
```
**Long, deep, positive conversation:**
```
base_delta = 0.8 * 0.5 = 0.4
length_bonus = min(0.3, 800/500) = 0.3 (capped)
depth_bonus = min(0.2, 5 * 0.05) = 0.2 (capped)
interaction_bonus = 0.1
total = 1.0 point (max)
```
---
## Interaction Tracking
Each interaction records:
| Metric | Description |
|--------|-------------|
| `total_interactions` | Total number of interactions |
| `positive_interactions` | Interactions with sentiment > 0.2 |
| `negative_interactions` | Interactions with sentiment < -0.2 |
| `avg_message_length` | Running average of message lengths |
| `conversation_depth_avg` | Running average of turns per conversation |
| `last_interaction_at` | When they last interacted |
| `first_interaction_at` | When they first interacted |
### Running Average Formula
```python
n = total_interactions
avg_message_length = ((avg_message_length * (n-1)) + new_length) / n
```
---
## Shared References
Close relationships can have shared references:
```python
shared_references = {
"jokes": ["the coffee incident", "404 life not found"],
"nicknames": ["debug buddy", "code wizard"],
"memories": ["that time we debugged for 3 hours"]
}
```
### Adding References
```python
await relationship_service.add_shared_reference(
user=user,
guild_id=123,
reference_type="jokes",
content="the coffee incident"
)
```
**Rules:**
- Maximum 10 references per type
- Oldest are removed when limit exceeded
- Duplicates are ignored
- Only mentioned for Good Friend (61+) and Close Friend (81+)
---
## Prompt Modifiers
The relationship level generates context for the AI:
### Base Modifier
```python
def get_relationship_prompt_modifier(level, relationship):
base = BASE_MODIFIERS[level] # Level-specific text
# Add shared references for close relationships
if level in (GOOD_FRIEND, CLOSE_FRIEND):
if relationship.shared_references.get("jokes"):
base += f" You have inside jokes: {jokes}"
if relationship.shared_references.get("nicknames"):
base += f" You sometimes call them: {nickname}"
return base
```
### Full Example Output
For a Close Friend with shared references:
```
This is a close friend! Be very casual and familiar.
Use inside jokes if you have any, be supportive and genuine.
You can tease them gently and be more emotionally open.
You have inside jokes together: the coffee incident, 404 life not found.
You sometimes call them: debug buddy.
```
---
## Database Schema
### UserRelationship Table
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `user_id` | Integer | Foreign key to users |
| `guild_id` | BigInteger | Guild ID (nullable for global) |
| `relationship_score` | Float | 0-100 score |
| `total_interactions` | Integer | Total interaction count |
| `positive_interactions` | Integer | Count of positive interactions |
| `negative_interactions` | Integer | Count of negative interactions |
| `avg_message_length` | Float | Average message length |
| `conversation_depth_avg` | Float | Average turns per conversation |
| `shared_references` | JSON | Dictionary of shared references |
| `first_interaction_at` | DateTime | First interaction timestamp |
| `last_interaction_at` | DateTime | Last interaction timestamp |
---
## API Reference
### RelationshipService
```python
class RelationshipService:
def __init__(self, session: AsyncSession)
async def get_or_create_relationship(
self,
user: User,
guild_id: int | None = None
) -> UserRelationship
async def record_interaction(
self,
user: User,
guild_id: int | None,
sentiment: float,
message_length: int,
conversation_turns: int = 1,
) -> RelationshipLevel
def get_level(self, score: float) -> RelationshipLevel
def get_level_display_name(self, level: RelationshipLevel) -> str
async def add_shared_reference(
self,
user: User,
guild_id: int | None,
reference_type: str,
content: str
) -> None
def get_relationship_prompt_modifier(
self,
level: RelationshipLevel,
relationship: UserRelationship
) -> str
async def get_relationship_info(
self,
user: User,
guild_id: int | None = None
) -> dict
```
### RelationshipLevel Enum
```python
class RelationshipLevel(Enum):
STRANGER = "stranger" # 0-20
ACQUAINTANCE = "acquaintance" # 21-40
FRIEND = "friend" # 41-60
GOOD_FRIEND = "good_friend" # 61-80
CLOSE_FRIEND = "close_friend" # 81-100
```
---
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `RELATIONSHIP_ENABLED` | `true` | Enable/disable relationship tracking |
---
## Example Usage
```python
from daemon_boyfriend.services.relationship_service import RelationshipService
async with get_session() as session:
rel_service = RelationshipService(session)
# Record an interaction
level = await rel_service.record_interaction(
user=user,
guild_id=123,
sentiment=0.5, # Positive interaction
message_length=150,
conversation_turns=3
)
print(f"Current level: {rel_service.get_level_display_name(level)}")
# Get full relationship info
info = await rel_service.get_relationship_info(user, guild_id=123)
print(f"Score: {info['score']}")
print(f"Total interactions: {info['total_interactions']}")
print(f"Days known: {info['days_known']}")
# Add a shared reference (for close friends)
await rel_service.add_shared_reference(
user=user,
guild_id=123,
reference_type="jokes",
content="the infinite loop joke"
)
# Get prompt modifier
relationship = await rel_service.get_or_create_relationship(user, 123)
modifier = rel_service.get_relationship_prompt_modifier(level, relationship)
print(f"Prompt modifier:\n{modifier}")
await session.commit()
```
---
## The !relationship Command
Users can check their relationship status:
```
User: !relationship
Bot: 📊 **Your Relationship with Me**
Level: **Friend** 🤝
Score: 52/100
We've had 47 conversations together!
- Positive vibes: 38 times
- Rough patches: 3 times
We've known each other for 23 days.
```
---
## Design Considerations
### Why Interaction Bonus?
Even negative interactions build relationship:
- Familiarity increases even through conflict
- Prevents relationships from degrading to zero
- Reflects real-world relationship dynamics
### Why Dampen Score Changes?
- Prevents gaming the system with spam
- Makes progression feel natural
- Single interactions have limited impact
- Requires sustained engagement to reach higher levels
### Guild-Specific vs Global
- Relationships are tracked per-guild by default
- Allows different relationship levels in different servers
- Set `guild_id=None` for global relationship tracking

615
docs/services/README.md Normal file
View File

@@ -0,0 +1,615 @@
# Services Reference
This document provides detailed API documentation for all services in the Daemon Boyfriend bot.
## Table of Contents
- [Core Services](#core-services)
- [AIService](#aiservice)
- [DatabaseService](#databaseservice)
- [UserService](#userservice)
- [ConversationManager](#conversationmanager)
- [PersistentConversationManager](#persistentconversationmanager)
- [SearXNGService](#searxngservice)
- [MonitoringService](#monitoringservice)
- [AI Providers](#ai-providers)
- [Living AI Services](#living-ai-services)
---
## Core Services
### AIService
**File:** `services/ai_service.py`
Factory and facade for AI providers. Manages provider creation, switching, and provides a unified interface for generating responses.
#### Initialization
```python
from daemon_boyfriend.services.ai_service import AIService
from daemon_boyfriend.config import settings
# Use default settings
ai_service = AIService()
# Or with custom settings
ai_service = AIService(config=custom_settings)
```
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `provider` | `AIProvider` | Current AI provider instance |
| `provider_name` | `str` | Name of current provider |
| `model` | `str` | Current model name |
#### Methods
##### `chat(messages, system_prompt) -> AIResponse`
Generate a chat response.
```python
from daemon_boyfriend.services.providers import Message
response = await ai_service.chat(
messages=[
Message(role="user", content="Hello!"),
],
system_prompt="You are a helpful assistant."
)
print(response.content) # AI's response
print(response.model) # Model used
print(response.usage) # Token usage
```
##### `get_system_prompt() -> str`
Get the base system prompt for the bot.
```python
prompt = ai_service.get_system_prompt()
# Returns: "You are Daemon, a friendly and helpful Discord bot..."
```
##### `get_enhanced_system_prompt(...) -> str`
Build system prompt with all personality modifiers.
```python
enhanced_prompt = ai_service.get_enhanced_system_prompt(
mood=mood_state,
relationship=(relationship_level, relationship_record),
communication_style=user_style,
bot_opinions=relevant_opinions
)
```
**Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `mood` | `MoodState \| None` | Current mood state |
| `relationship` | `tuple[RelationshipLevel, UserRelationship] \| None` | Relationship info |
| `communication_style` | `UserCommunicationStyle \| None` | User's preferences |
| `bot_opinions` | `list[BotOpinion] \| None` | Relevant opinions |
---
### DatabaseService
**File:** `services/database.py`
Manages database connections and sessions.
#### Global Instance
```python
from daemon_boyfriend.services.database import db, get_db
# Get global instance
db_service = get_db()
```
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `is_configured` | `bool` | Whether DATABASE_URL is set |
| `is_initialized` | `bool` | Whether connection is initialized |
#### Methods
##### `init() -> None`
Initialize database connection.
```python
await db.init()
```
##### `close() -> None`
Close database connection.
```python
await db.close()
```
##### `create_tables() -> None`
Create all tables from `schema.sql`.
```python
await db.create_tables()
```
##### `session() -> AsyncGenerator[AsyncSession, None]`
Get a database session with automatic commit/rollback.
```python
async with db.session() as session:
# Use session for database operations
user = await session.execute(select(User).where(...))
# Auto-commits on exit, auto-rollbacks on exception
```
---
### UserService
**File:** `services/user_service.py`
Service for user-related operations.
#### Initialization
```python
from daemon_boyfriend.services.user_service import UserService
async with db.session() as session:
user_service = UserService(session)
```
#### Methods
##### `get_or_create_user(discord_id, username, display_name) -> User`
Get existing user or create new one.
```python
user = await user_service.get_or_create_user(
discord_id=123456789,
username="john_doe",
display_name="John"
)
```
##### `get_user_by_discord_id(discord_id) -> User | None`
Get a user by their Discord ID.
```python
user = await user_service.get_user_by_discord_id(123456789)
```
##### `set_custom_name(discord_id, custom_name) -> User | None`
Set a custom name for a user.
```python
user = await user_service.set_custom_name(123456789, "Johnny")
# Or clear custom name
user = await user_service.set_custom_name(123456789, None)
```
##### `add_fact(user, fact_type, fact_content, source, confidence) -> UserFact`
Add a fact about a user.
```python
fact = await user_service.add_fact(
user=user,
fact_type="hobby",
fact_content="enjoys playing chess",
source="conversation",
confidence=0.9
)
```
##### `get_user_facts(user, fact_type, active_only) -> list[UserFact]`
Get facts about a user.
```python
# All active facts
facts = await user_service.get_user_facts(user)
# Only hobby facts
hobby_facts = await user_service.get_user_facts(user, fact_type="hobby")
# Including inactive facts
all_facts = await user_service.get_user_facts(user, active_only=False)
```
##### `delete_user_facts(user) -> int`
Soft-delete all facts for a user.
```python
count = await user_service.delete_user_facts(user)
print(f"Deactivated {count} facts")
```
##### `set_preference(user, key, value) -> UserPreference`
Set a user preference.
```python
await user_service.set_preference(user, "theme", "dark")
```
##### `get_preference(user, key) -> str | None`
Get a user preference value.
```python
theme = await user_service.get_preference(user, "theme")
```
##### `get_user_context(user) -> str`
Build a context string about a user for the AI.
```python
context = await user_service.get_user_context(user)
# Returns:
# User's preferred name: John
# (You should address them as: Johnny)
#
# Known facts about this user:
# - [hobby] enjoys playing chess
# - [work] software developer
```
##### `get_user_with_facts(discord_id) -> User | None`
Get a user with their facts eagerly loaded.
```python
user = await user_service.get_user_with_facts(123456789)
print(user.facts) # Facts already loaded
```
---
### ConversationManager
**File:** `services/conversation.py`
In-memory conversation history manager (used when no database).
#### Initialization
```python
from daemon_boyfriend.services.conversation import ConversationManager
conversation_manager = ConversationManager(max_history=50)
```
#### Methods
##### `add_message(user_id, role, content) -> None`
Add a message to a user's conversation history.
```python
conversation_manager.add_message(
user_id=123456789,
role="user",
content="Hello!"
)
```
##### `get_history(user_id) -> list[dict]`
Get conversation history for a user.
```python
history = conversation_manager.get_history(123456789)
# Returns: [{"role": "user", "content": "Hello!"}, ...]
```
##### `clear_history(user_id) -> None`
Clear conversation history for a user.
```python
conversation_manager.clear_history(123456789)
```
---
### PersistentConversationManager
**File:** `services/persistent_conversation.py`
Database-backed conversation manager.
#### Key Features
- Conversations persist across restarts
- Timeout-based conversation separation (60 min default)
- Per-guild conversation tracking
#### Methods
##### `get_or_create_conversation(session, user, guild_id) -> Conversation`
Get active conversation or create new one.
```python
conversation = await pcm.get_or_create_conversation(
session=session,
user=user,
guild_id=123456789
)
```
##### `add_message(session, conversation, role, content) -> Message`
Add a message to a conversation.
```python
message = await pcm.add_message(
session=session,
conversation=conversation,
role="user",
content="Hello!"
)
```
##### `get_recent_messages(session, conversation, limit) -> list[Message]`
Get recent messages from a conversation.
```python
messages = await pcm.get_recent_messages(
session=session,
conversation=conversation,
limit=20
)
```
---
### SearXNGService
**File:** `services/searxng.py`
Web search integration using SearXNG.
#### Initialization
```python
from daemon_boyfriend.services.searxng import SearXNGService
searxng = SearXNGService()
```
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `is_configured` | `bool` | Whether SEARXNG_URL is set |
#### Methods
##### `search(query, num_results) -> list[SearchResult]`
Search the web for a query.
```python
if searxng.is_configured:
results = await searxng.search(
query="Python 3.12 new features",
num_results=5
)
for result in results:
print(f"{result.title}: {result.url}")
print(f" {result.snippet}")
```
---
### MonitoringService
**File:** `services/monitoring.py`
Health checks and metrics tracking.
#### Features
- Request counting
- Response time tracking
- Error rate monitoring
- Health status checks
#### Usage
```python
from daemon_boyfriend.services.monitoring import MonitoringService
monitoring = MonitoringService()
# Record a request
monitoring.record_request()
# Record response time
monitoring.record_response_time(0.5) # 500ms
# Record an error
monitoring.record_error("API timeout")
# Get health status
status = monitoring.get_health_status()
print(status)
# {
# "status": "healthy",
# "uptime": 3600,
# "total_requests": 100,
# "avg_response_time": 0.3,
# "error_rate": 0.02,
# "recent_errors": [...]
# }
```
---
## AI Providers
**Location:** `services/providers/`
### Base Classes
```python
from daemon_boyfriend.services.providers import (
AIProvider, # Abstract base class
Message, # Chat message
AIResponse, # Response from provider
ImageAttachment, # Image attachment
)
```
### Message Class
```python
@dataclass
class Message:
role: str # "user", "assistant", "system"
content: str # Message content
images: list[ImageAttachment] = [] # Optional image attachments
```
### AIResponse Class
```python
@dataclass
class AIResponse:
content: str # Generated response
model: str # Model used
usage: dict[str, int] # Token usage info
```
### Available Providers
| Provider | Class | Description |
|----------|-------|-------------|
| OpenAI | `OpenAIProvider` | GPT models via OpenAI API |
| OpenRouter | `OpenRouterProvider` | Multiple models via OpenRouter |
| Anthropic | `AnthropicProvider` | Claude models via Anthropic API |
| Gemini | `GeminiProvider` | Gemini models via Google API |
### Provider Interface
All providers implement:
```python
class AIProvider(ABC):
@abstractmethod
async def generate(
self,
messages: list[Message],
system_prompt: str | None = None,
max_tokens: int = 1024,
temperature: float = 0.7,
) -> AIResponse:
pass
@property
@abstractmethod
def provider_name(self) -> str:
pass
```
### Example: Direct Provider Usage
```python
from daemon_boyfriend.services.providers import OpenAIProvider, Message
provider = OpenAIProvider(
api_key="sk-...",
model="gpt-4"
)
response = await provider.generate(
messages=[Message(role="user", content="Hello!")],
system_prompt="You are helpful.",
max_tokens=500,
temperature=0.7
)
print(response.content)
```
---
## Living AI Services
See [Living AI Documentation](../living-ai/README.md) for detailed coverage:
| Service | Documentation |
|---------|--------------|
| MoodService | [mood-system.md](../living-ai/mood-system.md) |
| RelationshipService | [relationship-system.md](../living-ai/relationship-system.md) |
| FactExtractionService | [fact-extraction.md](../living-ai/fact-extraction.md) |
| OpinionService | [opinion-system.md](../living-ai/opinion-system.md) |
| CommunicationStyleService | [Living AI README](../living-ai/README.md#5-communication-style-system) |
| ProactiveService | [Living AI README](../living-ai/README.md#6-proactive-events-system) |
| AssociationService | [Living AI README](../living-ai/README.md#7-association-system) |
| SelfAwarenessService | [Living AI README](../living-ai/README.md#8-self-awareness-system) |
---
## Service Dependency Graph
```
┌────────────────────────────────────────────────────────────────────────┐
│ AIChatCog │
└────────────────────────────────────────────────────────────────────────┘
┌───────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ UserService │ │ AIService │ │ Conversation │
│ │ │ │ │ Manager │
│ - get_user │ │ - chat │ │ │
│ - get_context │ │ - get_prompt │ │ - get_history │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Provider │ │ Living AI │ │
│ │ (OpenAI, │ │ Services │ │
│ │ Anthropic) │ │ │ │
│ └───────────────┘ └───────────────┘ │
│ │
└──────────────────┬───────────────────────────┘
┌───────────────┐
│ Database │
│ Service │
│ │
│ - session() │
└───────────────┘
```

View File

@@ -108,7 +108,7 @@ CREATE TABLE IF NOT EXISTS messages (
role VARCHAR(20) NOT NULL, role VARCHAR(20) NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
has_images BOOLEAN NOT NULL DEFAULT FALSE, has_images BOOLEAN NOT NULL DEFAULT FALSE,
image_urls TEXT[], image_urls JSONB,
token_count INTEGER, token_count INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()

View File

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

View File

@@ -2,9 +2,23 @@
from datetime import datetime, timezone 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.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 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: def utc_now() -> datetime:
@@ -12,6 +26,19 @@ def utc_now() -> datetime:
return datetime.now(timezone.utc) 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) # Naming convention for constraints (helps with migrations)
convention = { convention = {
"ix": "ix_%(column_0_label)s", "ix": "ix_%(column_0_label)s",

View File

@@ -3,10 +3,10 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, utc_now from .base import Base, PortableJSON, utc_now
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -51,7 +51,7 @@ class Message(Base):
role: Mapped[str] = mapped_column(String(20)) # user, assistant, system role: Mapped[str] = mapped_column(String(20)) # user, assistant, system
content: Mapped[str] = mapped_column(Text) content: Mapped[str] = mapped_column(Text)
has_images: Mapped[bool] = mapped_column(Boolean, default=False) 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) token_count: Mapped[int | None] = mapped_column(Integer)
# Relationships # Relationships

View File

@@ -4,19 +4,16 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
ARRAY,
BigInteger, BigInteger,
Boolean, Boolean,
DateTime, DateTime,
ForeignKey, ForeignKey,
String, String,
Text,
UniqueConstraint, UniqueConstraint,
) )
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, utc_now from .base import Base, PortableJSON, utc_now
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -32,7 +29,7 @@ class Guild(Base):
name: Mapped[str] = mapped_column(String(255)) name: Mapped[str] = mapped_column(String(255))
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) 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 # Relationships
members: Mapped[list["GuildMember"]] = relationship( 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) 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) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
guild_nickname: Mapped[str | None] = mapped_column(String(255)) 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) joined_guild_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
# Relationships # Relationships

View File

@@ -13,10 +13,9 @@ from sqlalchemy import (
Text, Text,
UniqueConstraint, UniqueConstraint,
) )
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, utc_now from .base import Base, PortableJSON, utc_now
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User, UserFact 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) first_activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
# Bot preferences (evolved over time) # Bot preferences (evolved over time)
preferences: Mapped[dict] = mapped_column(JSONB, default=dict) preferences: Mapped[dict] = mapped_column(PortableJSON, default=dict)
class BotOpinion(Base): class BotOpinion(Base):
@@ -88,7 +87,7 @@ class UserRelationship(Base):
conversation_depth_avg: Mapped[float] = mapped_column(Float, default=0.0) conversation_depth_avg: Mapped[float] = mapped_column(Float, default=0.0)
# Inside jokes / shared references # 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) 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) 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 detail_preference: Mapped[float] = mapped_column(Float, default=0.5) # 0=concise, 1=detailed
# Engagement signals used to learn preferences # 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) samples_collected: Mapped[int] = mapped_column(default=0)
confidence: Mapped[float] = mapped_column(Float, default=0.0) # 0-1 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) trigger_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
title: Mapped[str] = mapped_column(String(255)) 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) is_recurring: Mapped[bool] = mapped_column(Boolean, default=False)
recurrence_rule: Mapped[str | None] = mapped_column(String(100)) 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.config import settings
from daemon_boyfriend.models import BotState, MoodHistory from daemon_boyfriend.models import BotState, MoodHistory
from daemon_boyfriend.models.base import ensure_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -61,7 +62,7 @@ class MoodService:
# Apply time decay toward neutral # Apply time decay toward neutral
hours_since_update = ( 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 ).total_seconds() / 3600
decay_factor = max(0, 1 - (settings.mood_decay_rate * hours_since_update)) 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: async def get_stats(self, guild_id: int | None = None) -> dict:
"""Get bot statistics.""" """Get bot statistics."""
bot_state = await self.get_or_create_bot_state(guild_id) 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 { return {
"age_days": age_delta.days, "age_days": age_delta.days,

View File

@@ -8,6 +8,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from daemon_boyfriend.models import User, UserRelationship from daemon_boyfriend.models import User, UserRelationship
from daemon_boyfriend.models.base import ensure_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -211,7 +212,7 @@ class RelationshipService:
level = self.get_level(rel.relationship_score) level = self.get_level(rel.relationship_score)
# Calculate time since first interaction # 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 days_known = time_known.days
return { return {

View File

@@ -13,6 +13,7 @@ from daemon_boyfriend.models import (
UserFact, UserFact,
UserRelationship, UserRelationship,
) )
from daemon_boyfriend.models.base import ensure_utc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -36,7 +37,7 @@ class SelfAwarenessService:
await self._session.flush() await self._session.flush()
# Calculate age # 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) # Count users (from database)
user_count = await self._count_users() user_count = await self._count_users()
@@ -76,7 +77,7 @@ class SelfAwarenessService:
facts_count = facts_result.scalar() or 0 facts_count = facts_result.scalar() or 0
if rel: 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 { return {
"first_met": rel.first_interaction_at, "first_met": rel.first_interaction_at,
"days_known": days_known, "days_known": days_known,

View File

@@ -3,15 +3,16 @@
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from daemon_boyfriend.services.providers.anthropic import AnthropicProvider
from daemon_boyfriend.services.providers.base import ( from daemon_boyfriend.services.providers.base import (
AIProvider, AIProvider,
AIResponse, AIResponse,
Message,
ImageAttachment, 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.gemini import GeminiProvider
from daemon_boyfriend.services.providers.openai import OpenAIProvider
from daemon_boyfriend.services.providers.openrouter import OpenRouterProvider from daemon_boyfriend.services.providers.openrouter import OpenRouterProvider
@@ -144,12 +145,6 @@ class TestAnthropicProvider:
with patch( with patch(
"daemon_boyfriend.services.providers.anthropic.anthropic.AsyncAnthropic" "daemon_boyfriend.services.providers.anthropic.anthropic.AsyncAnthropic"
) as mock_class: ) 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 mock_class.return_value = mock_anthropic_client
provider = AnthropicProvider(api_key="test_key", model="claude-sonnet-4-20250514") provider = AnthropicProvider(api_key="test_key", model="claude-sonnet-4-20250514")
provider.client = mock_anthropic_client provider.client = mock_anthropic_client