Merge pull request 'dev' (#17) from dev into main
Reviewed-on: MSC/Daemon-Boyfriend#17
This commit is contained in:
@@ -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
103
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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
195
docs/README.md
Normal 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
431
docs/architecture.md
Normal 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
637
docs/configuration.md
Normal 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
570
docs/database.md
Normal 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
589
docs/guides/README.md
Normal 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
301
docs/living-ai/README.md
Normal 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)
|
||||||
441
docs/living-ai/fact-extraction.md
Normal file
441
docs/living-ai/fact-extraction.md
Normal 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)
|
||||||
338
docs/living-ai/mood-system.md
Normal file
338
docs/living-ai/mood-system.md
Normal 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()
|
||||||
|
```
|
||||||
418
docs/living-ai/opinion-system.md
Normal file
418
docs/living-ai/opinion-system.md
Normal 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)
|
||||||
417
docs/living-ai/relationship-system.md
Normal file
417
docs/living-ai/relationship-system.md
Normal 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
615
docs/services/README.md
Normal 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() │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user