Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd42586df | |||
| d371fb77cf | |||
| 743bed67f3 | |||
| bf01724b3e | |||
| 0d43b5b29a |
71
.env.example
71
.env.example
@@ -29,16 +29,16 @@ AI_TEMPERATURE=0.7
|
|||||||
# Bot Identity & Personality
|
# Bot Identity & Personality
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# The bot's name, used in the system prompt to tell the AI who it is
|
# The bot's name, used in the system prompt to tell the AI who it is
|
||||||
BOT_NAME=My Bot
|
BOT_NAME="My Bot"
|
||||||
|
|
||||||
# Personality traits that define how the bot responds (used in system prompt)
|
# Personality traits that define how the bot responds (used in system prompt)
|
||||||
BOT_PERSONALITY=helpful and friendly
|
BOT_PERSONALITY="helpful and friendly"
|
||||||
|
|
||||||
# Message shown when someone mentions the bot without saying anything
|
# Message shown when someone mentions the bot without saying anything
|
||||||
BOT_DESCRIPTION=I'm an AI assistant here to help you.
|
BOT_DESCRIPTION="I'm an AI assistant here to help you."
|
||||||
|
|
||||||
# Status message shown in Discord (displays as "Watching <BOT_STATUS>")
|
# Status message shown in Discord (displays as "Watching <BOT_STATUS>")
|
||||||
BOT_STATUS=for mentions
|
BOT_STATUS="for mentions"
|
||||||
|
|
||||||
# Optional: Override the entire system prompt (leave commented to use auto-generated)
|
# Optional: Override the entire system prompt (leave commented to use auto-generated)
|
||||||
# SYSTEM_PROMPT=You are a custom assistant...
|
# SYSTEM_PROMPT=You are a custom assistant...
|
||||||
@@ -82,6 +82,56 @@ SEARXNG_ENABLED=true
|
|||||||
# Maximum number of search results to fetch (1-20)
|
# Maximum number of search results to fetch (1-20)
|
||||||
SEARXNG_MAX_RESULTS=5
|
SEARXNG_MAX_RESULTS=5
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Living AI Configuration
|
||||||
|
# ===========================================
|
||||||
|
# Master switch for all Living AI features
|
||||||
|
LIVING_AI_ENABLED=true
|
||||||
|
|
||||||
|
# Enable mood system (bot has emotional states that affect responses)
|
||||||
|
MOOD_ENABLED=true
|
||||||
|
|
||||||
|
# Enable relationship tracking (Stranger -> Close Friend progression)
|
||||||
|
RELATIONSHIP_ENABLED=true
|
||||||
|
|
||||||
|
# Enable autonomous fact extraction (bot learns from conversations)
|
||||||
|
FACT_EXTRACTION_ENABLED=true
|
||||||
|
|
||||||
|
# Probability of extracting facts from messages (0.0-1.0)
|
||||||
|
FACT_EXTRACTION_RATE=0.3
|
||||||
|
|
||||||
|
# Enable proactive messages (birthdays, follow-ups)
|
||||||
|
PROACTIVE_ENABLED=true
|
||||||
|
|
||||||
|
# Enable cross-user associations (privacy-sensitive - shows shared interests)
|
||||||
|
CROSS_USER_ENABLED=true
|
||||||
|
|
||||||
|
# Enable bot opinion formation (bot develops topic preferences)
|
||||||
|
OPINION_FORMATION_ENABLED=true
|
||||||
|
|
||||||
|
# Enable communication style learning (bot adapts to user preferences)
|
||||||
|
STYLE_LEARNING_ENABLED=true
|
||||||
|
|
||||||
|
# How fast mood returns to neutral per hour (0.0-1.0)
|
||||||
|
MOOD_DECAY_RATE=0.1
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Command Toggles
|
||||||
|
# ===========================================
|
||||||
|
# Master switch for all commands (when false, bot handles via conversation)
|
||||||
|
COMMANDS_ENABLED=false
|
||||||
|
|
||||||
|
# Individual command toggles
|
||||||
|
# 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 & Monitoring
|
# Logging & Monitoring
|
||||||
# ===========================================
|
# ===========================================
|
||||||
@@ -111,3 +161,16 @@ LOG_LEVEL=INFO
|
|||||||
# Admin Memory:
|
# Admin Memory:
|
||||||
# !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
|
||||||
|
#
|
||||||
|
# Living AI:
|
||||||
|
# !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 (e.g., !birthday March 15)
|
||||||
|
#
|
||||||
|
# Note: When commands are disabled, the bot handles these naturally:
|
||||||
|
# - "what do you know about me?" instead of !whatdoyouknow
|
||||||
|
# - "call me Alex" instead of !setname
|
||||||
|
# - "how are you feeling?" instead of !mood
|
||||||
|
# - "my birthday is March 15th" instead of !birthday
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -57,3 +57,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# SearXNG config (may contain secrets)
|
# SearXNG config (may contain secrets)
|
||||||
searxng/settings.yml
|
searxng/settings.yml
|
||||||
|
|
||||||
|
# Database files (if using SQLite for testing)
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|||||||
75
README.md
75
README.md
@@ -12,6 +12,17 @@ A customizable Discord bot that responds to @mentions with AI-generated response
|
|||||||
- **Fully Customizable**: Configure bot name, personality, and behavior
|
- **Fully Customizable**: Configure bot name, personality, and behavior
|
||||||
- **Easy Deployment**: Docker support with PostgreSQL included
|
- **Easy Deployment**: Docker support with PostgreSQL included
|
||||||
|
|
||||||
|
### Living AI Features
|
||||||
|
|
||||||
|
- **Autonomous Learning**: Bot automatically extracts and remembers facts from conversations
|
||||||
|
- **Mood System**: Bot has emotional states that affect its responses naturally
|
||||||
|
- **Relationship Tracking**: Bot builds relationships from Stranger to Close Friend
|
||||||
|
- **Communication Style Learning**: Bot adapts to each user's preferred style
|
||||||
|
- **Opinion Formation**: Bot develops genuine preferences on topics
|
||||||
|
- **Proactive Behavior**: Birthday wishes, follow-ups on mentioned events
|
||||||
|
- **Self-Awareness**: Bot knows its age, statistics, and history with users
|
||||||
|
- **Cross-User Connections**: Bot can identify shared interests between users
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Clone the repository
|
### 1. Clone the repository
|
||||||
@@ -104,6 +115,38 @@ When `DATABASE_URL` is set, the bot uses PostgreSQL for persistent storage. With
|
|||||||
|
|
||||||
When configured, the bot automatically searches the web for queries that need current information (news, weather, etc.).
|
When configured, the bot automatically searches the web for queries that need current information (news, weather, etc.).
|
||||||
|
|
||||||
|
### Living AI Settings
|
||||||
|
|
||||||
|
| 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.0-1.0) |
|
||||||
|
| `PROACTIVE_ENABLED` | `true` | Enable proactive messages (birthdays, follow-ups) |
|
||||||
|
| `CROSS_USER_ENABLED` | `false` | Enable cross-user associations (privacy-sensitive) |
|
||||||
|
| `OPINION_FORMATION_ENABLED` | `true` | Enable bot opinion formation |
|
||||||
|
| `STYLE_LEARNING_ENABLED` | `true` | Enable communication style learning |
|
||||||
|
| `MOOD_DECAY_RATE` | `0.1` | How fast mood returns to neutral per hour |
|
||||||
|
|
||||||
|
### Command Toggles
|
||||||
|
|
||||||
|
All commands can be individually enabled/disabled. When disabled, the bot handles these functions naturally through conversation.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `COMMANDS_ENABLED` | `true` | Master switch for all commands |
|
||||||
|
| `CMD_RELATIONSHIP_ENABLED` | `true` | Enable `!relationship` command |
|
||||||
|
| `CMD_MOOD_ENABLED` | `true` | Enable `!mood` command |
|
||||||
|
| `CMD_BOTSTATS_ENABLED` | `true` | Enable `!botstats` command |
|
||||||
|
| `CMD_OURHISTORY_ENABLED` | `true` | Enable `!ourhistory` command |
|
||||||
|
| `CMD_BIRTHDAY_ENABLED` | `true` | Enable `!birthday` command |
|
||||||
|
| `CMD_REMEMBER_ENABLED` | `true` | Enable `!remember` command |
|
||||||
|
| `CMD_SETNAME_ENABLED` | `true` | Enable `!setname` command |
|
||||||
|
| `CMD_WHATDOYOUKNOW_ENABLED` | `true` | Enable `!whatdoyouknow` command |
|
||||||
|
| `CMD_FORGETME_ENABLED` | `true` | Enable `!forgetme` command |
|
||||||
|
|
||||||
### Example Configurations
|
### Example Configurations
|
||||||
|
|
||||||
**Friendly Assistant:**
|
**Friendly Assistant:**
|
||||||
@@ -171,6 +214,22 @@ 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 |
|
||||||
|
|
||||||
|
### Living AI Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `!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 |
|
||||||
|
|
||||||
|
**Note:** When commands are disabled, the bot handles these naturally through conversation:
|
||||||
|
- Ask "what do you know about me?" instead of `!whatdoyouknow`
|
||||||
|
- Say "call me Alex" instead of `!setname Alex`
|
||||||
|
- Ask "how are you feeling?" instead of `!mood`
|
||||||
|
- Say "my birthday is March 15th" instead of `!birthday`
|
||||||
|
|
||||||
## AI Providers
|
## AI Providers
|
||||||
|
|
||||||
| Provider | Models | Notes |
|
| Provider | Models | Notes |
|
||||||
@@ -193,15 +252,25 @@ src/daemon_boyfriend/
|
|||||||
├── models/
|
├── models/
|
||||||
│ ├── user.py # User, UserFact, UserPreference
|
│ ├── user.py # User, UserFact, UserPreference
|
||||||
│ ├── conversation.py # Conversation, Message
|
│ ├── conversation.py # Conversation, Message
|
||||||
│ └── guild.py # Guild, GuildMember
|
│ ├── guild.py # Guild, GuildMember
|
||||||
|
│ └── living_ai.py # BotState, UserRelationship, etc.
|
||||||
└── services/
|
└── services/
|
||||||
├── ai_service.py # AI provider factory
|
├── ai_service.py # AI provider factory
|
||||||
├── database.py # PostgreSQL connection
|
├── database.py # PostgreSQL connection
|
||||||
├── user_service.py # User management
|
├── user_service.py # User management
|
||||||
├── persistent_conversation.py # DB-backed history
|
├── persistent_conversation.py # DB-backed history
|
||||||
├── providers/ # AI providers
|
├── providers/ # AI providers
|
||||||
└── searxng.py # Web search service
|
├── searxng.py # Web search service
|
||||||
alembic/ # Database migrations
|
├── mood_service.py # Mood system
|
||||||
|
├── relationship_service.py # Relationship tracking
|
||||||
|
├── fact_extraction_service.py # Autonomous learning
|
||||||
|
├── communication_style_service.py # Style learning
|
||||||
|
├── opinion_service.py # Opinion formation
|
||||||
|
├── proactive_service.py # Scheduled events
|
||||||
|
├── self_awareness_service.py # Bot self-reflection
|
||||||
|
└── association_service.py # Cross-user connections
|
||||||
|
schema.sql # Database schema
|
||||||
|
project-vision.md # Living AI design document
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
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:
|
environment:
|
||||||
POSTGRES_USER: daemon
|
POSTGRES_USER: daemon
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon}
|
||||||
POSTGRES_DB: daemon_boyfriend
|
POSTGRES_DB: daemon_boyfriend
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
|
||||||
test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"]
|
healthcheck:
|
||||||
interval: 10s
|
test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"]
|
||||||
timeout: 5s
|
interval: 10s
|
||||||
retries: 5
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
408
project-vision.md
Normal file
408
project-vision.md
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
# Project Vision: Living AI Discord Bot
|
||||||
|
|
||||||
|
Transform the Daemon Boyfriend Discord bot from a reactive chatbot into a truly **living AI companion** with persistent memory, emotional depth, evolving relationships, and autonomous learning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Philosophy
|
||||||
|
|
||||||
|
The bot should feel like a **living entity** that:
|
||||||
|
- Remembers and learns without being explicitly told
|
||||||
|
- Has moods that influence its personality
|
||||||
|
- Builds genuine relationships over time
|
||||||
|
- Develops its own opinions and preferences
|
||||||
|
- Proactively engages when appropriate
|
||||||
|
- Adapts its communication style to each person
|
||||||
|
- Reflects on its own existence and growth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
### 1. Autonomous Fact Learning
|
||||||
|
|
||||||
|
**Current**: Users must use `!remember` to save facts.
|
||||||
|
**Vision**: The bot automatically extracts and remembers important information from conversations.
|
||||||
|
|
||||||
|
```
|
||||||
|
User: "I just got back from my trip to Japan, it was amazing!"
|
||||||
|
Bot: (internally saves: user visited Japan, user enjoys travel)
|
||||||
|
Bot: "That sounds incredible! What was the highlight of your trip?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
- AI-powered fact extraction after each message (rate-limited to ~30%)
|
||||||
|
- Automatic deduplication and conflict resolution
|
||||||
|
- Facts categorized by type: hobby, work, family, preference, event, location
|
||||||
|
- Importance scoring to prioritize relevant facts in context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Emotional/Mood System
|
||||||
|
|
||||||
|
**Vision**: The bot has internal emotional states that affect its responses naturally.
|
||||||
|
|
||||||
|
**Mood Model** (Valence-Arousal):
|
||||||
|
| Mood | Valence | Arousal | Behavior |
|
||||||
|
|------|---------|---------|----------|
|
||||||
|
| Excited | High | High | Enthusiastic, uses exclamations |
|
||||||
|
| Happy | High | Low | Warm, friendly, content |
|
||||||
|
| Curious | Neutral | High | Asks questions, shows interest |
|
||||||
|
| Calm | Neutral | Low | Thoughtful, measured responses |
|
||||||
|
| Bored | Low | Low | Shorter responses, topic steering |
|
||||||
|
| Annoyed | Low | High | Terse, less patient |
|
||||||
|
|
||||||
|
**Mood Influences**:
|
||||||
|
- Positive interactions → happier mood
|
||||||
|
- Interesting discussions → higher arousal/curiosity
|
||||||
|
- Being ignored or insulted → negative mood shifts
|
||||||
|
- Time decay → mood gradually returns to neutral
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
[After an exciting conversation about gaming]
|
||||||
|
Bot (excited mood): "Oh man, that reminds me of when I first heard about that game!
|
||||||
|
Have you tried the multiplayer yet?!"
|
||||||
|
|
||||||
|
[After hours of no interaction]
|
||||||
|
Bot (calm/neutral mood): "Hey. What's on your mind?"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Relationship Tracking
|
||||||
|
|
||||||
|
**Vision**: The bot tracks relationship depth with each user and adjusts its behavior accordingly.
|
||||||
|
|
||||||
|
**Relationship Levels**:
|
||||||
|
| Level | Score | Behavior |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Stranger | 0-20 | Polite, formal, reserved |
|
||||||
|
| Acquaintance | 21-40 | Friendly but professional |
|
||||||
|
| Friend | 41-60 | Casual, uses names, warm |
|
||||||
|
| Good Friend | 61-80 | Personal, references past talks |
|
||||||
|
| Close Friend | 81-100 | Very casual, inside jokes, supportive |
|
||||||
|
|
||||||
|
**Relationship Growth**:
|
||||||
|
- Increases with: positive interactions, longer conversations, depth of topics
|
||||||
|
- Decreases with: negative interactions, long absences, being ignored
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Inside jokes accumulate over time
|
||||||
|
- Nicknames and shared references remembered
|
||||||
|
- Different greeting styles based on familiarity
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
[Stranger]
|
||||||
|
Bot: "Hello! How can I help you today?"
|
||||||
|
|
||||||
|
[Close Friend]
|
||||||
|
Bot: "Yooo what's up! Still working on that project you mentioned?"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Cross-User Memory Associations
|
||||||
|
|
||||||
|
**Vision**: The bot connects knowledge across users to facilitate social connections.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
User A: "I really love rock climbing"
|
||||||
|
[Bot remembers: User A likes rock climbing]
|
||||||
|
|
||||||
|
[Later, User B mentions rock climbing]
|
||||||
|
Bot: "Nice! You know, Alice is also really into rock climbing.
|
||||||
|
You two might have some good stories to share!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases**:
|
||||||
|
- Identify shared interests
|
||||||
|
- Suggest connections between users
|
||||||
|
- Reference mutual friends appropriately
|
||||||
|
- Build community awareness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Proactive Behavior
|
||||||
|
|
||||||
|
**Vision**: The bot initiates meaningful interactions when appropriate.
|
||||||
|
|
||||||
|
**Types of Proactive Messages**:
|
||||||
|
|
||||||
|
1. **Birthday Wishes**
|
||||||
|
- Detects birthday mentions and schedules yearly wishes
|
||||||
|
- Personalized based on relationship level
|
||||||
|
|
||||||
|
2. **Follow-ups**
|
||||||
|
- "Hey, how did that job interview go?"
|
||||||
|
- "Did you finish that project you were working on?"
|
||||||
|
- Detects future events mentioned in conversation
|
||||||
|
|
||||||
|
3. **Check-ins** (optional)
|
||||||
|
- Occasional messages to long-absent friends
|
||||||
|
- Based on relationship strength
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
User: "I have a big exam tomorrow, really nervous"
|
||||||
|
[Bot schedules follow-up for day after]
|
||||||
|
|
||||||
|
[Next day]
|
||||||
|
Bot: "Hey! How did your exam go? Hope it went well!"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Communication Style Learning
|
||||||
|
|
||||||
|
**Vision**: The bot adapts its communication style to match each user's preferences.
|
||||||
|
|
||||||
|
**Learned Preferences**:
|
||||||
|
| Preference | Range | Effect |
|
||||||
|
|------------|-------|--------|
|
||||||
|
| Response Length | Short/Medium/Long | Adjusts verbosity |
|
||||||
|
| Formality | Casual ↔ Formal | Word choice, structure |
|
||||||
|
| Emoji Usage | None ↔ Lots | Emoji frequency |
|
||||||
|
| Humor | Serious ↔ Playful | Joke frequency |
|
||||||
|
| Detail Level | Concise ↔ Detailed | Explanation depth |
|
||||||
|
|
||||||
|
**Learning Signals**:
|
||||||
|
- User's own message length and style
|
||||||
|
- Whether user continues conversation after certain responses
|
||||||
|
- Positive engagement patterns
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
[User who prefers short responses]
|
||||||
|
Bot: "Nice! Good luck with that."
|
||||||
|
|
||||||
|
[User who prefers detailed responses]
|
||||||
|
Bot: "That's a really interesting approach! I think there are a few
|
||||||
|
things to consider here. First, you might want to look at..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Opinion Formation
|
||||||
|
|
||||||
|
**Vision**: The bot develops genuine preferences based on its experiences.
|
||||||
|
|
||||||
|
**How Opinions Form**:
|
||||||
|
- Topics discussed frequently → develops interest
|
||||||
|
- Positive conversation outcomes → positive sentiment
|
||||||
|
- Topics that generate engagement → preference grows
|
||||||
|
|
||||||
|
**Stored Per Topic**:
|
||||||
|
- Sentiment (-1 to +1)
|
||||||
|
- Interest level (0 to 1)
|
||||||
|
- Discussion count
|
||||||
|
- Reasoning (why the bot feels this way)
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
User: "What do you think about philosophy?"
|
||||||
|
Bot: "I actually find philosophy really fascinating! I've had some
|
||||||
|
great conversations about ethics and existence. There's something
|
||||||
|
about exploring the big questions that I find really engaging."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Self-Awareness
|
||||||
|
|
||||||
|
**Vision**: The bot has awareness of its own existence, history, and growth.
|
||||||
|
|
||||||
|
**Self-Knowledge**:
|
||||||
|
- How long it has been active ("I've been around for 3 months")
|
||||||
|
- How many people it knows ("I've met 127 people")
|
||||||
|
- How many facts it has learned ("I've learned 892 things about people")
|
||||||
|
- Its favorite topics and why
|
||||||
|
- History with specific users ("We first met back in October")
|
||||||
|
|
||||||
|
**Commands**:
|
||||||
|
- `!botstats` - Bot shares its statistics
|
||||||
|
- `!ourhistory` - Bot describes its history with the user
|
||||||
|
- `!relationship` - Shows relationship level and metrics
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
User: "Tell me about yourself"
|
||||||
|
Bot: "Well, I've been around for about 3 months now. I've gotten to
|
||||||
|
know 127 different people and learned almost 900 things about them.
|
||||||
|
I've noticed I really enjoy conversations about games and philosophy.
|
||||||
|
As for us, we first met about 6 weeks ago, and you've taught me
|
||||||
|
12 things about yourself. I'd say we're pretty good friends at this point!"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### New Database Tables
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `bot_states` | Global mood, statistics, preferences |
|
||||||
|
| `bot_opinions` | Topic sentiments and preferences |
|
||||||
|
| `user_relationships` | Per-user relationship scores and metrics |
|
||||||
|
| `user_communication_styles` | Learned communication preferences |
|
||||||
|
| `scheduled_events` | Birthdays, follow-ups, reminders |
|
||||||
|
| `fact_associations` | Cross-user memory links |
|
||||||
|
| `mood_history` | Mood changes over time |
|
||||||
|
|
||||||
|
### New Services
|
||||||
|
|
||||||
|
| Service | Responsibility |
|
||||||
|
|---------|---------------|
|
||||||
|
| `MoodService` | Mood tracking, decay, prompt modification |
|
||||||
|
| `RelationshipService` | Relationship scoring and level management |
|
||||||
|
| `CommunicationStyleService` | Style learning and adaptation |
|
||||||
|
| `FactExtractionService` | Autonomous fact detection and storage |
|
||||||
|
| `ProactiveService` | Scheduled events and follow-ups |
|
||||||
|
| `AssociationService` | Cross-user memory connections |
|
||||||
|
| `SelfAwarenessService` | Bot statistics and self-reflection |
|
||||||
|
|
||||||
|
### Enhanced System Prompt
|
||||||
|
|
||||||
|
The system prompt becomes dynamic, incorporating:
|
||||||
|
```
|
||||||
|
[Base Personality]
|
||||||
|
You are Daemon Boyfriend, a charming Discord bot...
|
||||||
|
|
||||||
|
[Current Mood]
|
||||||
|
You're feeling curious and engaged right now.
|
||||||
|
|
||||||
|
[Relationship Context]
|
||||||
|
This is a good friend. Be casual and personal, reference past conversations.
|
||||||
|
|
||||||
|
[Communication Style]
|
||||||
|
This user prefers concise responses with occasional humor.
|
||||||
|
|
||||||
|
[Your Opinions]
|
||||||
|
You enjoy discussing games and philosophy.
|
||||||
|
|
||||||
|
[User Context]
|
||||||
|
User's name: Alex
|
||||||
|
Known facts:
|
||||||
|
- Loves programming in Python
|
||||||
|
- Recently started a new job
|
||||||
|
- Has a cat named Whiskers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Background Tasks
|
||||||
|
|
||||||
|
| Task | Frequency | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Mood decay | 30 min | Return mood to neutral over time |
|
||||||
|
| Event checker | 5 min | Trigger scheduled messages |
|
||||||
|
| Association discovery | Hourly | Find cross-user connections |
|
||||||
|
| Opinion formation | Daily | Update topic preferences |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation
|
||||||
|
- Mood system (valence-arousal model, time decay)
|
||||||
|
- Basic relationship tracking (score, level)
|
||||||
|
- Enhanced system prompt with mood/relationship modifiers
|
||||||
|
|
||||||
|
### Phase 2: Autonomous Learning
|
||||||
|
- Fact extraction service
|
||||||
|
- AI-powered fact detection
|
||||||
|
- Deduplication and importance scoring
|
||||||
|
|
||||||
|
### Phase 3: Personalization
|
||||||
|
- Communication style learning
|
||||||
|
- Opinion formation
|
||||||
|
- Self-awareness service and commands
|
||||||
|
|
||||||
|
### Phase 4: Proactive Features
|
||||||
|
- Scheduled events system
|
||||||
|
- Follow-up detection
|
||||||
|
- Birthday wishes
|
||||||
|
|
||||||
|
### Phase 5: Social Features
|
||||||
|
- Cross-user associations
|
||||||
|
- Connection suggestions
|
||||||
|
- Guild-wide personality adaptation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Living AI Features
|
||||||
|
LIVING_AI_ENABLED=true
|
||||||
|
FACT_EXTRACTION_RATE=0.3 # 30% of messages analyzed
|
||||||
|
MOOD_ENABLED=true
|
||||||
|
PROACTIVE_ENABLED=true
|
||||||
|
CROSS_USER_ENABLED=false # Optional privacy-sensitive feature
|
||||||
|
|
||||||
|
# Command Toggles (set to false to disable)
|
||||||
|
COMMANDS_ENABLED=true # Master switch for all commands
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Commands
|
||||||
|
|
||||||
|
| Command | Description | Config Toggle |
|
||||||
|
|---------|-------------|---------------|
|
||||||
|
| `!relationship` | See your relationship level with the bot | `CMD_RELATIONSHIP_ENABLED` |
|
||||||
|
| `!mood` | See the bot's current emotional state | `CMD_MOOD_ENABLED` |
|
||||||
|
| `!botstats` | Bot shares its self-awareness statistics | `CMD_BOTSTATS_ENABLED` |
|
||||||
|
| `!ourhistory` | See your history with the bot | `CMD_OURHISTORY_ENABLED` |
|
||||||
|
| `!birthday <date>` | Set your birthday for the bot to remember | `CMD_BIRTHDAY_ENABLED` |
|
||||||
|
| `!remember <fact>` | Tell the bot something about you | `CMD_REMEMBER_ENABLED` |
|
||||||
|
| `!setname <name>` | Set your preferred name | `CMD_SETNAME_ENABLED` |
|
||||||
|
| `!whatdoyouknow` | See what the bot remembers about you | `CMD_WHATDOYOUKNOW_ENABLED` |
|
||||||
|
| `!forgetme` | Clear all facts about you | `CMD_FORGETME_ENABLED` |
|
||||||
|
|
||||||
|
All commands can be individually enabled/disabled via environment variables. Set `COMMANDS_ENABLED=false` to disable all commands at once.
|
||||||
|
|
||||||
|
**Important**: When commands are disabled, the bot still performs these functions naturally through conversation:
|
||||||
|
- **No `!remember`** → Bot automatically learns facts from what users say
|
||||||
|
- **No `!setname`** → Bot picks up preferred names from conversation ("call me Alex")
|
||||||
|
- **No `!whatdoyouknow`** → Users can ask naturally ("what do you know about me?") and the bot responds
|
||||||
|
- **No `!forgetme`** → Users can say "forget everything about me" and the bot will comply
|
||||||
|
- **No `!mood`** → Users can ask "how are you feeling?" and the bot shares its mood
|
||||||
|
- **No `!relationship`** → Users can ask "how well do you know me?" naturally
|
||||||
|
- **No `!botstats`** → Users can ask "tell me about yourself" and bot shares its history
|
||||||
|
- **No `!ourhistory`** → Users can ask "how long have we known each other?"
|
||||||
|
- **No `!birthday`** → Bot detects birthday mentions ("my birthday is March 15th")
|
||||||
|
|
||||||
|
This allows for a more natural, command-free experience where all interactions happen through normal conversation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
The Living AI is successful when:
|
||||||
|
- Users feel the bot "knows" them without explicit commands
|
||||||
|
- Conversations feel more natural and personalized
|
||||||
|
- Users notice and appreciate the bot's personality consistency
|
||||||
|
- The bot's opinions and preferences feel genuine
|
||||||
|
- Proactive messages feel thoughtful, not annoying
|
||||||
|
- Relationship progression feels earned and meaningful
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy Considerations
|
||||||
|
|
||||||
|
- All fact learning is opt-out via `!forgetme`
|
||||||
|
- Cross-user associations can be disabled server-wide
|
||||||
|
- Proactive messages respect user preferences
|
||||||
|
- All data can be exported or deleted on request
|
||||||
|
- Clear indication when bot learns something new (optional setting)
|
||||||
143
schema.sql
143
schema.sql
@@ -117,3 +117,146 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
CREATE INDEX IF NOT EXISTS ix_messages_conversation_id ON messages(conversation_id);
|
CREATE INDEX IF NOT EXISTS ix_messages_conversation_id ON messages(conversation_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_messages_user_id ON messages(user_id);
|
CREATE INDEX IF NOT EXISTS ix_messages_user_id ON messages(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_messages_created_at ON messages(created_at);
|
CREATE INDEX IF NOT EXISTS ix_messages_created_at ON messages(created_at);
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- LIVING AI TABLES
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Bot state table (mood, statistics, preferences per guild)
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_states (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT UNIQUE, -- NULL = global state
|
||||||
|
mood_valence FLOAT DEFAULT 0.0, -- -1.0 (sad) to 1.0 (happy)
|
||||||
|
mood_arousal FLOAT DEFAULT 0.0, -- -1.0 (calm) to 1.0 (excited)
|
||||||
|
mood_updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
total_messages_sent INTEGER DEFAULT 0,
|
||||||
|
total_facts_learned INTEGER DEFAULT 0,
|
||||||
|
total_users_known INTEGER DEFAULT 0,
|
||||||
|
first_activated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
preferences JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_bot_states_guild_id ON bot_states(guild_id);
|
||||||
|
|
||||||
|
-- Bot opinions table (topic preferences)
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_opinions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT, -- NULL = global opinion
|
||||||
|
topic VARCHAR(255) NOT NULL,
|
||||||
|
sentiment FLOAT DEFAULT 0.0, -- -1.0 to 1.0
|
||||||
|
interest_level FLOAT DEFAULT 0.5, -- 0.0 to 1.0
|
||||||
|
discussion_count INTEGER DEFAULT 0,
|
||||||
|
reasoning TEXT,
|
||||||
|
formed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_reinforced_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(guild_id, topic)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_bot_opinions_guild_id ON bot_opinions(guild_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_bot_opinions_topic ON bot_opinions(topic);
|
||||||
|
|
||||||
|
-- User relationships table (relationship depth tracking)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_relationships (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
guild_id BIGINT, -- NULL = global relationship
|
||||||
|
relationship_score FLOAT DEFAULT 10.0, -- 0-100 scale
|
||||||
|
total_interactions INTEGER DEFAULT 0,
|
||||||
|
positive_interactions INTEGER DEFAULT 0,
|
||||||
|
negative_interactions INTEGER DEFAULT 0,
|
||||||
|
avg_message_length FLOAT DEFAULT 0.0,
|
||||||
|
conversation_depth_avg FLOAT DEFAULT 0.0,
|
||||||
|
shared_references JSONB DEFAULT '{}',
|
||||||
|
first_interaction_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
last_interaction_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, guild_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_user_relationships_user_id ON user_relationships(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_user_relationships_guild_id ON user_relationships(guild_id);
|
||||||
|
|
||||||
|
-- User communication styles table (learned preferences)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_communication_styles (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
preferred_length VARCHAR(20) DEFAULT 'medium', -- short/medium/long
|
||||||
|
preferred_formality FLOAT DEFAULT 0.5, -- 0=casual, 1=formal
|
||||||
|
emoji_affinity FLOAT DEFAULT 0.5, -- 0=none, 1=lots
|
||||||
|
humor_affinity FLOAT DEFAULT 0.5, -- 0=serious, 1=playful
|
||||||
|
detail_preference FLOAT DEFAULT 0.5, -- 0=concise, 1=detailed
|
||||||
|
engagement_signals JSONB DEFAULT '{}',
|
||||||
|
samples_collected INTEGER DEFAULT 0,
|
||||||
|
confidence FLOAT DEFAULT 0.0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_user_communication_styles_user_id ON user_communication_styles(user_id);
|
||||||
|
|
||||||
|
-- Scheduled events table (proactive behavior)
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduled_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
guild_id BIGINT,
|
||||||
|
channel_id BIGINT,
|
||||||
|
event_type VARCHAR(50) NOT NULL, -- birthday, follow_up, reminder, etc.
|
||||||
|
trigger_at TIMESTAMPTZ NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
context JSONB DEFAULT '{}',
|
||||||
|
is_recurring BOOLEAN DEFAULT FALSE,
|
||||||
|
recurrence_rule VARCHAR(100), -- yearly, monthly, etc.
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, triggered, cancelled
|
||||||
|
triggered_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_scheduled_events_user_id ON scheduled_events(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_scheduled_events_trigger_at ON scheduled_events(trigger_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_scheduled_events_status ON scheduled_events(status);
|
||||||
|
|
||||||
|
-- Fact associations table (cross-user memory links)
|
||||||
|
CREATE TABLE IF NOT EXISTS fact_associations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
fact_id_1 BIGINT NOT NULL REFERENCES user_facts(id) ON DELETE CASCADE,
|
||||||
|
fact_id_2 BIGINT NOT NULL REFERENCES user_facts(id) ON DELETE CASCADE,
|
||||||
|
association_type VARCHAR(50) NOT NULL, -- shared_interest, same_location, etc.
|
||||||
|
strength FLOAT DEFAULT 0.5,
|
||||||
|
discovered_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(fact_id_1, fact_id_2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_fact_associations_fact_id_1 ON fact_associations(fact_id_1);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_fact_associations_fact_id_2 ON fact_associations(fact_id_2);
|
||||||
|
|
||||||
|
-- Mood history table (track mood changes over time)
|
||||||
|
CREATE TABLE IF NOT EXISTS mood_history (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
guild_id BIGINT,
|
||||||
|
valence FLOAT NOT NULL,
|
||||||
|
arousal FLOAT NOT NULL,
|
||||||
|
trigger_type VARCHAR(50) NOT NULL, -- conversation, time_decay, event
|
||||||
|
trigger_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
trigger_description TEXT,
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_mood_history_guild_id ON mood_history(guild_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_mood_history_recorded_at ON mood_history(recorded_at);
|
||||||
|
|
||||||
|
-- Add new columns to user_facts for enhanced memory
|
||||||
|
ALTER TABLE user_facts ADD COLUMN IF NOT EXISTS category VARCHAR(50);
|
||||||
|
ALTER TABLE user_facts ADD COLUMN IF NOT EXISTS importance FLOAT DEFAULT 0.5;
|
||||||
|
ALTER TABLE user_facts ADD COLUMN IF NOT EXISTS temporal_relevance VARCHAR(20);
|
||||||
|
ALTER TABLE user_facts ADD COLUMN IF NOT EXISTS expiry_date TIMESTAMPTZ;
|
||||||
|
ALTER TABLE user_facts ADD COLUMN IF NOT EXISTS extracted_from_message_id BIGINT;
|
||||||
|
ALTER TABLE user_facts ADD COLUMN IF NOT EXISTS extraction_context TEXT;
|
||||||
|
|||||||
@@ -9,13 +9,22 @@ from discord.ext import commands
|
|||||||
from daemon_boyfriend.config import settings
|
from daemon_boyfriend.config import settings
|
||||||
from daemon_boyfriend.services import (
|
from daemon_boyfriend.services import (
|
||||||
AIService,
|
AIService,
|
||||||
|
CommunicationStyleService,
|
||||||
ConversationManager,
|
ConversationManager,
|
||||||
|
FactExtractionService,
|
||||||
ImageAttachment,
|
ImageAttachment,
|
||||||
Message,
|
Message,
|
||||||
|
MoodService,
|
||||||
|
OpinionService,
|
||||||
PersistentConversationManager,
|
PersistentConversationManager,
|
||||||
|
ProactiveService,
|
||||||
|
RelationshipService,
|
||||||
SearXNGService,
|
SearXNGService,
|
||||||
UserService,
|
UserService,
|
||||||
db,
|
db,
|
||||||
|
detect_emoji_usage,
|
||||||
|
detect_formal_language,
|
||||||
|
extract_topics_from_message,
|
||||||
)
|
)
|
||||||
from daemon_boyfriend.utils import get_monitor
|
from daemon_boyfriend.utils import get_monitor
|
||||||
|
|
||||||
@@ -414,6 +423,8 @@ class AIChatCog(commands.Cog):
|
|||||||
async with db.session() as session:
|
async with db.session() as session:
|
||||||
user_service = UserService(session)
|
user_service = UserService(session)
|
||||||
conv_manager = PersistentConversationManager(session)
|
conv_manager = PersistentConversationManager(session)
|
||||||
|
mood_service = MoodService(session)
|
||||||
|
relationship_service = RelationshipService(session)
|
||||||
|
|
||||||
# Get or create user
|
# Get or create user
|
||||||
user = await user_service.get_or_create_user(
|
user = await user_service.get_or_create_user(
|
||||||
@@ -422,10 +433,12 @@ class AIChatCog(commands.Cog):
|
|||||||
display_name=message.author.display_name,
|
display_name=message.author.display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
guild_id = message.guild.id if message.guild else None
|
||||||
|
|
||||||
# Get or create conversation
|
# Get or create conversation
|
||||||
conversation = await conv_manager.get_or_create_conversation(
|
conversation = await conv_manager.get_or_create_conversation(
|
||||||
user=user,
|
user=user,
|
||||||
guild_id=message.guild.id if message.guild else None,
|
guild_id=guild_id,
|
||||||
channel_id=message.channel.id,
|
channel_id=message.channel.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -446,8 +459,43 @@ class AIChatCog(commands.Cog):
|
|||||||
# Get context about mentioned users
|
# Get context about mentioned users
|
||||||
mentioned_users_context = self._get_mentioned_users_context(message)
|
mentioned_users_context = self._get_mentioned_users_context(message)
|
||||||
|
|
||||||
# Build system prompt with additional context
|
# Get Living AI context (mood, relationship, style, opinions)
|
||||||
system_prompt = self.ai_service.get_system_prompt()
|
mood = None
|
||||||
|
relationship_data = None
|
||||||
|
communication_style = None
|
||||||
|
relevant_opinions = None
|
||||||
|
|
||||||
|
if settings.living_ai_enabled:
|
||||||
|
if settings.mood_enabled:
|
||||||
|
mood = await mood_service.get_current_mood(guild_id)
|
||||||
|
|
||||||
|
if settings.relationship_enabled:
|
||||||
|
rel = await relationship_service.get_or_create_relationship(user, guild_id)
|
||||||
|
level = relationship_service.get_level(rel.relationship_score)
|
||||||
|
relationship_data = (level, rel)
|
||||||
|
|
||||||
|
if settings.style_learning_enabled:
|
||||||
|
style_service = CommunicationStyleService(session)
|
||||||
|
communication_style = await style_service.get_or_create_style(user)
|
||||||
|
|
||||||
|
if settings.opinion_formation_enabled:
|
||||||
|
opinion_service = OpinionService(session)
|
||||||
|
topics = extract_topics_from_message(user_message)
|
||||||
|
if topics:
|
||||||
|
relevant_opinions = await opinion_service.get_relevant_opinions(
|
||||||
|
topics, guild_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build system prompt with personality context
|
||||||
|
if settings.living_ai_enabled and (mood or relationship_data or communication_style):
|
||||||
|
system_prompt = self.ai_service.get_enhanced_system_prompt(
|
||||||
|
mood=mood,
|
||||||
|
relationship=relationship_data,
|
||||||
|
communication_style=communication_style,
|
||||||
|
bot_opinions=relevant_opinions,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
system_prompt = self.ai_service.get_system_prompt()
|
||||||
|
|
||||||
# Add user context from database (custom name, known facts)
|
# Add user context from database (custom name, known facts)
|
||||||
user_context = await user_service.get_user_context(user)
|
user_context = await user_service.get_user_context(user)
|
||||||
@@ -482,6 +530,20 @@ class AIChatCog(commands.Cog):
|
|||||||
image_urls=image_urls,
|
image_urls=image_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Post-response Living AI updates (mood, relationship, style, opinions, facts, proactive)
|
||||||
|
if settings.living_ai_enabled:
|
||||||
|
await self._update_living_ai_state(
|
||||||
|
session=session,
|
||||||
|
user=user,
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_id=message.channel.id,
|
||||||
|
user_message=user_message,
|
||||||
|
bot_response=response.content,
|
||||||
|
discord_message_id=message.id,
|
||||||
|
mood_service=mood_service,
|
||||||
|
relationship_service=relationship_service,
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Generated response for user {user.discord_id}: "
|
f"Generated response for user {user.discord_id}: "
|
||||||
f"{len(response.content)} chars, {response.usage}"
|
f"{len(response.content)} chars, {response.usage}"
|
||||||
@@ -489,6 +551,171 @@ class AIChatCog(commands.Cog):
|
|||||||
|
|
||||||
return response.content
|
return response.content
|
||||||
|
|
||||||
|
async def _update_living_ai_state(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
guild_id: int | None,
|
||||||
|
channel_id: int,
|
||||||
|
user_message: str,
|
||||||
|
bot_response: str,
|
||||||
|
discord_message_id: int,
|
||||||
|
mood_service: MoodService,
|
||||||
|
relationship_service: RelationshipService,
|
||||||
|
) -> None:
|
||||||
|
"""Update Living AI state after a response (mood, relationship, style, opinions, facts, proactive)."""
|
||||||
|
try:
|
||||||
|
# Simple sentiment estimation based on message characteristics
|
||||||
|
sentiment = self._estimate_sentiment(user_message)
|
||||||
|
engagement = min(1.0, len(user_message) / 300) # Longer = more engaged
|
||||||
|
|
||||||
|
# Update mood
|
||||||
|
if settings.mood_enabled:
|
||||||
|
await mood_service.update_mood(
|
||||||
|
guild_id=guild_id,
|
||||||
|
sentiment_delta=sentiment * 0.5,
|
||||||
|
engagement_delta=engagement * 0.5,
|
||||||
|
trigger_type="conversation",
|
||||||
|
trigger_user_id=user.id,
|
||||||
|
trigger_description=f"Conversation with {user.display_name}",
|
||||||
|
)
|
||||||
|
# Increment message count
|
||||||
|
await mood_service.increment_stats(guild_id, messages_sent=1)
|
||||||
|
|
||||||
|
# Update relationship
|
||||||
|
if settings.relationship_enabled:
|
||||||
|
await relationship_service.record_interaction(
|
||||||
|
user=user,
|
||||||
|
guild_id=guild_id,
|
||||||
|
sentiment=sentiment,
|
||||||
|
message_length=len(user_message),
|
||||||
|
conversation_turns=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update communication style learning
|
||||||
|
if settings.style_learning_enabled:
|
||||||
|
style_service = CommunicationStyleService(session)
|
||||||
|
await style_service.record_engagement(
|
||||||
|
user=user,
|
||||||
|
user_message_length=len(user_message),
|
||||||
|
bot_response_length=len(bot_response),
|
||||||
|
conversation_continued=True, # Assume continued for now
|
||||||
|
user_used_emoji=detect_emoji_usage(user_message),
|
||||||
|
user_used_formal_language=detect_formal_language(user_message),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update opinion tracking
|
||||||
|
if settings.opinion_formation_enabled:
|
||||||
|
topics = extract_topics_from_message(user_message)
|
||||||
|
if topics:
|
||||||
|
opinion_service = OpinionService(session)
|
||||||
|
for topic in topics[:3]: # Limit to 3 topics per message
|
||||||
|
await opinion_service.record_topic_discussion(
|
||||||
|
topic=topic,
|
||||||
|
guild_id=guild_id,
|
||||||
|
sentiment=sentiment,
|
||||||
|
engagement_level=engagement,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Autonomous fact extraction (rate-limited internally)
|
||||||
|
if settings.fact_extraction_enabled:
|
||||||
|
fact_service = FactExtractionService(session, self.ai_service)
|
||||||
|
new_facts = await fact_service.maybe_extract_facts(
|
||||||
|
user=user,
|
||||||
|
message_content=user_message,
|
||||||
|
discord_message_id=discord_message_id,
|
||||||
|
)
|
||||||
|
if new_facts:
|
||||||
|
# Update stats for facts learned
|
||||||
|
await mood_service.increment_stats(guild_id, facts_learned=len(new_facts))
|
||||||
|
logger.debug(f"Auto-extracted {len(new_facts)} facts from message")
|
||||||
|
|
||||||
|
# Proactive event detection (follow-ups, birthdays)
|
||||||
|
if settings.proactive_enabled:
|
||||||
|
proactive_service = ProactiveService(session, self.ai_service)
|
||||||
|
|
||||||
|
# Try to detect follow-up opportunities (rate-limited by message length)
|
||||||
|
if len(user_message) > 30: # Only check substantial messages
|
||||||
|
await proactive_service.detect_and_schedule_followup(
|
||||||
|
user=user,
|
||||||
|
message_content=user_message,
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to detect birthday mentions
|
||||||
|
await proactive_service.detect_and_schedule_birthday(
|
||||||
|
user=user,
|
||||||
|
message_content=user_message,
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update Living AI state: {e}")
|
||||||
|
|
||||||
|
def _estimate_sentiment(self, text: str) -> float:
|
||||||
|
"""Estimate sentiment from text using simple heuristics.
|
||||||
|
|
||||||
|
Returns a value from -1 (negative) to 1 (positive).
|
||||||
|
This is a placeholder until we add AI-based sentiment analysis.
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
# Positive indicators
|
||||||
|
positive_words = [
|
||||||
|
"thanks",
|
||||||
|
"thank you",
|
||||||
|
"awesome",
|
||||||
|
"great",
|
||||||
|
"love",
|
||||||
|
"amazing",
|
||||||
|
"wonderful",
|
||||||
|
"excellent",
|
||||||
|
"perfect",
|
||||||
|
"happy",
|
||||||
|
"glad",
|
||||||
|
"appreciate",
|
||||||
|
"helpful",
|
||||||
|
"nice",
|
||||||
|
"good",
|
||||||
|
"cool",
|
||||||
|
"fantastic",
|
||||||
|
"brilliant",
|
||||||
|
]
|
||||||
|
# Negative indicators
|
||||||
|
negative_words = [
|
||||||
|
"hate",
|
||||||
|
"awful",
|
||||||
|
"terrible",
|
||||||
|
"bad",
|
||||||
|
"stupid",
|
||||||
|
"annoying",
|
||||||
|
"frustrated",
|
||||||
|
"angry",
|
||||||
|
"disappointed",
|
||||||
|
"wrong",
|
||||||
|
"broken",
|
||||||
|
"useless",
|
||||||
|
"horrible",
|
||||||
|
"worst",
|
||||||
|
"sucks",
|
||||||
|
"boring",
|
||||||
|
]
|
||||||
|
|
||||||
|
positive_count = sum(1 for word in positive_words if word in text_lower)
|
||||||
|
negative_count = sum(1 for word in negative_words if word in text_lower)
|
||||||
|
|
||||||
|
# Check for exclamation marks (usually positive energy)
|
||||||
|
exclamation_bonus = min(0.2, text.count("!") * 0.05)
|
||||||
|
|
||||||
|
# Calculate sentiment
|
||||||
|
if positive_count + negative_count == 0:
|
||||||
|
return 0.1 + exclamation_bonus # Slightly positive by default
|
||||||
|
|
||||||
|
sentiment = (positive_count - negative_count) / (positive_count + negative_count)
|
||||||
|
return max(-1.0, min(1.0, sentiment + exclamation_bonus))
|
||||||
|
|
||||||
async def _generate_response_in_memory(
|
async def _generate_response_in_memory(
|
||||||
self, message: discord.Message, user_message: str
|
self, message: discord.Message, user_message: str
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -87,6 +87,38 @@ class Settings(BaseSettings):
|
|||||||
searxng_enabled: bool = Field(True, description="Enable web search capability")
|
searxng_enabled: bool = Field(True, description="Enable web search capability")
|
||||||
searxng_max_results: int = Field(5, ge=1, le=20, description="Maximum search results to fetch")
|
searxng_max_results: int = Field(5, ge=1, le=20, description="Maximum search results to fetch")
|
||||||
|
|
||||||
|
# Living AI Configuration
|
||||||
|
living_ai_enabled: bool = Field(True, description="Enable Living AI features")
|
||||||
|
mood_enabled: bool = Field(True, description="Enable mood system")
|
||||||
|
relationship_enabled: bool = Field(True, description="Enable relationship tracking")
|
||||||
|
fact_extraction_enabled: bool = Field(True, description="Enable autonomous fact extraction")
|
||||||
|
fact_extraction_rate: float = Field(
|
||||||
|
0.3, ge=0.0, le=1.0, description="Probability of extracting facts from messages"
|
||||||
|
)
|
||||||
|
proactive_enabled: bool = Field(True, description="Enable proactive messages")
|
||||||
|
cross_user_enabled: bool = Field(
|
||||||
|
False, description="Enable cross-user memory associations (privacy-sensitive)"
|
||||||
|
)
|
||||||
|
opinion_formation_enabled: bool = Field(True, description="Enable bot opinion formation")
|
||||||
|
style_learning_enabled: bool = Field(True, description="Enable communication style learning")
|
||||||
|
|
||||||
|
# Mood System Settings
|
||||||
|
mood_decay_rate: float = Field(
|
||||||
|
0.1, ge=0.0, le=1.0, description="How fast mood returns to neutral per hour"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Command Toggles
|
||||||
|
commands_enabled: bool = Field(True, description="Master switch for all commands")
|
||||||
|
cmd_relationship_enabled: bool = Field(True, description="Enable !relationship command")
|
||||||
|
cmd_mood_enabled: bool = Field(True, description="Enable !mood command")
|
||||||
|
cmd_botstats_enabled: bool = Field(True, description="Enable !botstats command")
|
||||||
|
cmd_ourhistory_enabled: bool = Field(True, description="Enable !ourhistory command")
|
||||||
|
cmd_birthday_enabled: bool = Field(True, description="Enable !birthday command")
|
||||||
|
cmd_remember_enabled: bool = Field(True, description="Enable !remember command")
|
||||||
|
cmd_setname_enabled: bool = Field(True, description="Enable !setname command")
|
||||||
|
cmd_whatdoyouknow_enabled: bool = Field(True, description="Enable !whatdoyouknow command")
|
||||||
|
cmd_forgetme_enabled: bool = Field(True, description="Enable !forgetme command")
|
||||||
|
|
||||||
def get_api_key(self) -> str:
|
def get_api_key(self) -> str:
|
||||||
"""Get the API key for the configured provider."""
|
"""Get the API key for the configured provider."""
|
||||||
key_map = {
|
key_map = {
|
||||||
|
|||||||
@@ -3,15 +3,31 @@
|
|||||||
from .base import Base
|
from .base import Base
|
||||||
from .conversation import Conversation, Message
|
from .conversation import Conversation, Message
|
||||||
from .guild import Guild, GuildMember
|
from .guild import Guild, GuildMember
|
||||||
|
from .living_ai import (
|
||||||
|
BotOpinion,
|
||||||
|
BotState,
|
||||||
|
FactAssociation,
|
||||||
|
MoodHistory,
|
||||||
|
ScheduledEvent,
|
||||||
|
UserCommunicationStyle,
|
||||||
|
UserRelationship,
|
||||||
|
)
|
||||||
from .user import User, UserFact, UserPreference
|
from .user import User, UserFact, UserPreference
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
"BotOpinion",
|
||||||
|
"BotState",
|
||||||
"Conversation",
|
"Conversation",
|
||||||
|
"FactAssociation",
|
||||||
"Guild",
|
"Guild",
|
||||||
"GuildMember",
|
"GuildMember",
|
||||||
"Message",
|
"Message",
|
||||||
|
"MoodHistory",
|
||||||
|
"ScheduledEvent",
|
||||||
"User",
|
"User",
|
||||||
|
"UserCommunicationStyle",
|
||||||
"UserFact",
|
"UserFact",
|
||||||
"UserPreference",
|
"UserPreference",
|
||||||
|
"UserRelationship",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
"""SQLAlchemy base model and metadata configuration."""
|
"""SQLAlchemy base model and metadata configuration."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import MetaData
|
from sqlalchemy import DateTime, MetaData
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
"""Return current UTC time as timezone-aware datetime."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
# 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",
|
||||||
@@ -23,6 +29,8 @@ class Base(AsyncAttrs, DeclarativeBase):
|
|||||||
|
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
|
|
||||||
# Common timestamp columns
|
# Common timestamp columns - use timezone-aware datetimes
|
||||||
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utc_now, onupdate=utc_now
|
||||||
|
)
|
||||||
|
|||||||
@@ -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, ForeignKey, Integer, String, Text
|
from sqlalchemy import ARRAY, 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
|
from .base import Base, utc_now
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .user import User
|
from .user import User
|
||||||
@@ -21,8 +21,10 @@ class Conversation(Base):
|
|||||||
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_id: Mapped[int | None] = mapped_column(BigInteger)
|
guild_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
channel_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
channel_id: Mapped[int | None] = mapped_column(BigInteger, index=True)
|
||||||
started_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
last_message_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, index=True)
|
last_message_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utc_now, index=True
|
||||||
|
)
|
||||||
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
message_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,20 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ARRAY, BigInteger, Boolean, ForeignKey, String, Text, UniqueConstraint
|
from sqlalchemy import (
|
||||||
|
ARRAY,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
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
|
from .base import Base, utc_now
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .user import User
|
from .user import User
|
||||||
@@ -21,7 +30,7 @@ class Guild(Base):
|
|||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
discord_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
discord_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
joined_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
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(JSONB, default=dict)
|
||||||
|
|
||||||
@@ -41,7 +50,7 @@ class GuildMember(Base):
|
|||||||
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(ARRAY(Text), default=None)
|
||||||
joined_guild_at: Mapped[datetime | None] = mapped_column(default=None)
|
joined_guild_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
guild: Mapped["Guild"] = relationship(back_populates="members")
|
guild: Mapped["Guild"] = relationship(back_populates="members")
|
||||||
|
|||||||
197
src/daemon_boyfriend/models/living_ai.py
Normal file
197
src/daemon_boyfriend/models/living_ai.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""Living AI database models - mood, relationships, opinions, and more."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from .base import Base, utc_now
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .user import User, UserFact
|
||||||
|
|
||||||
|
|
||||||
|
class BotState(Base):
|
||||||
|
"""Global bot state - mood, statistics, preferences per guild."""
|
||||||
|
|
||||||
|
__tablename__ = "bot_states"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
guild_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
|
||||||
|
|
||||||
|
# Current mood state (valence-arousal model)
|
||||||
|
mood_valence: Mapped[float] = mapped_column(Float, default=0.0) # -1.0 (sad) to 1.0 (happy)
|
||||||
|
mood_arousal: Mapped[float] = mapped_column(Float, default=0.0) # -1.0 (calm) to 1.0 (excited)
|
||||||
|
mood_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
|
||||||
|
# Bot statistics
|
||||||
|
total_messages_sent: Mapped[int] = mapped_column(default=0)
|
||||||
|
total_facts_learned: Mapped[int] = mapped_column(default=0)
|
||||||
|
total_users_known: Mapped[int] = mapped_column(default=0)
|
||||||
|
first_activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
|
||||||
|
# Bot preferences (evolved over time)
|
||||||
|
preferences: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class BotOpinion(Base):
|
||||||
|
"""Bot's opinions and preferences on topics."""
|
||||||
|
|
||||||
|
__tablename__ = "bot_opinions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
guild_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
topic: Mapped[str] = mapped_column(String(255), index=True)
|
||||||
|
sentiment: Mapped[float] = mapped_column(Float, default=0.0) # -1.0 to 1.0
|
||||||
|
interest_level: Mapped[float] = mapped_column(Float, default=0.5) # 0.0 to 1.0
|
||||||
|
discussion_count: Mapped[int] = mapped_column(default=0)
|
||||||
|
|
||||||
|
reasoning: Mapped[str | None] = mapped_column(Text)
|
||||||
|
formed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
last_reinforced_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("guild_id", "topic"),)
|
||||||
|
|
||||||
|
|
||||||
|
class UserRelationship(Base):
|
||||||
|
"""Tracks relationship depth and dynamics with each user."""
|
||||||
|
|
||||||
|
__tablename__ = "user_relationships"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
|
guild_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
# Relationship level (0-100 scale)
|
||||||
|
# 0-20: stranger, 21-40: acquaintance, 41-60: friend, 61-80: good friend, 81-100: close friend
|
||||||
|
relationship_score: Mapped[float] = mapped_column(Float, default=10.0)
|
||||||
|
|
||||||
|
# Interaction metrics
|
||||||
|
total_interactions: Mapped[int] = mapped_column(default=0)
|
||||||
|
positive_interactions: Mapped[int] = mapped_column(default=0)
|
||||||
|
negative_interactions: Mapped[int] = mapped_column(default=0)
|
||||||
|
|
||||||
|
# Engagement quality
|
||||||
|
avg_message_length: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
conversation_depth_avg: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|
||||||
|
# Inside jokes / shared references
|
||||||
|
shared_references: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
first_interaction_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
last_interaction_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(back_populates="relationships")
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "guild_id"),)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCommunicationStyle(Base):
|
||||||
|
"""Learned communication preferences per user."""
|
||||||
|
|
||||||
|
__tablename__ = "user_communication_styles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Response style preferences (learned from engagement patterns)
|
||||||
|
preferred_length: Mapped[str] = mapped_column(String(20), default="medium")
|
||||||
|
preferred_formality: Mapped[float] = mapped_column(Float, default=0.5) # 0=casual, 1=formal
|
||||||
|
emoji_affinity: Mapped[float] = mapped_column(Float, default=0.5) # 0=none, 1=lots
|
||||||
|
humor_affinity: Mapped[float] = mapped_column(Float, default=0.5) # 0=serious, 1=playful
|
||||||
|
detail_preference: Mapped[float] = mapped_column(Float, default=0.5) # 0=concise, 1=detailed
|
||||||
|
|
||||||
|
# Engagement signals used to learn preferences
|
||||||
|
engagement_signals: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
samples_collected: Mapped[int] = mapped_column(default=0)
|
||||||
|
confidence: Mapped[float] = mapped_column(Float, default=0.0) # 0-1
|
||||||
|
|
||||||
|
# Relationship
|
||||||
|
user: Mapped["User"] = relationship(back_populates="communication_style")
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledEvent(Base):
|
||||||
|
"""Events the bot should act on (birthdays, follow-ups, etc.)."""
|
||||||
|
|
||||||
|
__tablename__ = "scheduled_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
guild_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
channel_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50), index=True)
|
||||||
|
trigger_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
|
context: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
recurrence_rule: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||||
|
triggered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship(back_populates="scheduled_events")
|
||||||
|
|
||||||
|
|
||||||
|
class FactAssociation(Base):
|
||||||
|
"""Links facts across users for cross-user memory."""
|
||||||
|
|
||||||
|
__tablename__ = "fact_associations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
||||||
|
fact_id_1: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("user_facts.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
fact_id_2: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("user_facts.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
association_type: Mapped[str] = mapped_column(String(50))
|
||||||
|
strength: Mapped[float] = mapped_column(Float, default=0.5)
|
||||||
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
fact_1: Mapped["UserFact"] = relationship(foreign_keys=[fact_id_1])
|
||||||
|
fact_2: Mapped["UserFact"] = relationship(foreign_keys=[fact_id_2])
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("fact_id_1", "fact_id_2"),)
|
||||||
|
|
||||||
|
|
||||||
|
class MoodHistory(Base):
|
||||||
|
"""Track mood changes over time for reflection."""
|
||||||
|
|
||||||
|
__tablename__ = "mood_history"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
guild_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
valence: Mapped[float] = mapped_column(Float)
|
||||||
|
arousal: Mapped[float] = mapped_column(Float)
|
||||||
|
|
||||||
|
trigger_type: Mapped[str] = mapped_column(String(50)) # conversation, time_decay, event
|
||||||
|
trigger_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||||
|
trigger_description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utc_now, index=True
|
||||||
|
)
|
||||||
@@ -3,14 +3,24 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, Float, ForeignKey, String, Text, UniqueConstraint
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .base import Base
|
from .base import Base, utc_now
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .conversation import Conversation, Message
|
from .conversation import Conversation, Message
|
||||||
from .guild import GuildMember
|
from .guild import GuildMember
|
||||||
|
from .living_ai import ScheduledEvent, UserCommunicationStyle, UserRelationship
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@@ -23,8 +33,8 @@ class User(Base):
|
|||||||
discord_username: Mapped[str] = mapped_column(String(255))
|
discord_username: Mapped[str] = mapped_column(String(255))
|
||||||
discord_display_name: Mapped[str | None] = mapped_column(String(255))
|
discord_display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
custom_name: Mapped[str | None] = mapped_column(String(255))
|
custom_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
first_seen_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
last_seen_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
last_seen_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)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -42,6 +52,17 @@ class User(Base):
|
|||||||
back_populates="user", cascade="all, delete-orphan"
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Living AI relationships
|
||||||
|
relationships: Mapped[list["UserRelationship"]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
communication_style: Mapped["UserCommunicationStyle | None"] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan", uselist=False
|
||||||
|
)
|
||||||
|
scheduled_events: Mapped[list["ScheduledEvent"]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self) -> str:
|
def display_name(self) -> str:
|
||||||
"""Get the name to use when addressing this user."""
|
"""Get the name to use when addressing this user."""
|
||||||
@@ -76,8 +97,10 @@ class UserFact(Base):
|
|||||||
confidence: Mapped[float] = mapped_column(Float, default=1.0)
|
confidence: Mapped[float] = mapped_column(Float, default=1.0)
|
||||||
source: Mapped[str] = mapped_column(String(50), default="conversation")
|
source: Mapped[str] = mapped_column(String(50), default="conversation")
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
learned_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
learned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
last_referenced_at: Mapped[datetime | None] = mapped_column(default=None)
|
last_referenced_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=None
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
user: Mapped["User"] = relationship(back_populates="facts")
|
user: Mapped["User"] = relationship(back_populates="facts")
|
||||||
|
|||||||
@@ -1,23 +1,49 @@
|
|||||||
"""Services for external integrations."""
|
"""Services for external integrations."""
|
||||||
|
|
||||||
from .ai_service import AIService
|
from .ai_service import AIService
|
||||||
|
from .association_service import AssociationService
|
||||||
|
from .communication_style_service import (
|
||||||
|
CommunicationStyleService,
|
||||||
|
detect_emoji_usage,
|
||||||
|
detect_formal_language,
|
||||||
|
)
|
||||||
from .conversation import ConversationManager
|
from .conversation import ConversationManager
|
||||||
from .database import DatabaseService, db, get_db
|
from .database import DatabaseService, db, get_db
|
||||||
|
from .fact_extraction_service import FactExtractionService
|
||||||
|
from .mood_service import MoodLabel, MoodService, MoodState
|
||||||
|
from .opinion_service import OpinionService, extract_topics_from_message
|
||||||
from .persistent_conversation import PersistentConversationManager
|
from .persistent_conversation import PersistentConversationManager
|
||||||
|
from .proactive_service import ProactiveService
|
||||||
from .providers import AIResponse, ImageAttachment, Message
|
from .providers import AIResponse, ImageAttachment, Message
|
||||||
|
from .relationship_service import RelationshipLevel, RelationshipService
|
||||||
from .searxng import SearXNGService
|
from .searxng import SearXNGService
|
||||||
|
from .self_awareness_service import SelfAwarenessService
|
||||||
from .user_service import UserService
|
from .user_service import UserService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AIService",
|
"AIService",
|
||||||
"AIResponse",
|
"AIResponse",
|
||||||
|
"AssociationService",
|
||||||
|
"CommunicationStyleService",
|
||||||
"ConversationManager",
|
"ConversationManager",
|
||||||
"DatabaseService",
|
"DatabaseService",
|
||||||
|
"FactExtractionService",
|
||||||
"ImageAttachment",
|
"ImageAttachment",
|
||||||
"Message",
|
"Message",
|
||||||
|
"MoodLabel",
|
||||||
|
"MoodService",
|
||||||
|
"MoodState",
|
||||||
|
"OpinionService",
|
||||||
"PersistentConversationManager",
|
"PersistentConversationManager",
|
||||||
|
"ProactiveService",
|
||||||
|
"RelationshipLevel",
|
||||||
|
"RelationshipService",
|
||||||
"SearXNGService",
|
"SearXNGService",
|
||||||
|
"SelfAwarenessService",
|
||||||
"UserService",
|
"UserService",
|
||||||
"db",
|
"db",
|
||||||
|
"detect_emoji_usage",
|
||||||
|
"detect_formal_language",
|
||||||
|
"extract_topics_from_message",
|
||||||
"get_db",
|
"get_db",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""AI Service - Factory and facade for AI providers."""
|
"""AI Service - Factory and facade for AI providers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
from daemon_boyfriend.config import Settings, settings
|
from daemon_boyfriend.config import Settings, settings
|
||||||
|
|
||||||
@@ -15,6 +17,12 @@ from .providers import (
|
|||||||
OpenRouterProvider,
|
OpenRouterProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from daemon_boyfriend.models import BotOpinion, UserCommunicationStyle, UserRelationship
|
||||||
|
|
||||||
|
from .mood_service import MoodState
|
||||||
|
from .relationship_service import RelationshipLevel
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ProviderType = Literal["openai", "openrouter", "anthropic", "gemini"]
|
ProviderType = Literal["openai", "openrouter", "anthropic", "gemini"]
|
||||||
@@ -106,3 +114,90 @@ class AIService:
|
|||||||
f"Discord bot. Keep your responses concise and engaging. "
|
f"Discord bot. Keep your responses concise and engaging. "
|
||||||
f"You can use Discord markdown formatting in your responses."
|
f"You can use Discord markdown formatting in your responses."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_enhanced_system_prompt(
|
||||||
|
self,
|
||||||
|
mood: MoodState | None = None,
|
||||||
|
relationship: tuple[RelationshipLevel, UserRelationship] | None = None,
|
||||||
|
communication_style: UserCommunicationStyle | None = None,
|
||||||
|
bot_opinions: list[BotOpinion] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build system prompt with all personality modifiers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mood: Current mood state
|
||||||
|
relationship: Tuple of (level, relationship_record)
|
||||||
|
communication_style: User's learned communication preferences
|
||||||
|
bot_opinions: Bot's opinions relevant to the conversation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Enhanced system prompt with personality context
|
||||||
|
"""
|
||||||
|
from .mood_service import MoodService
|
||||||
|
from .relationship_service import RelationshipService
|
||||||
|
|
||||||
|
base_prompt = self.get_system_prompt()
|
||||||
|
modifiers = []
|
||||||
|
|
||||||
|
# Add mood modifier
|
||||||
|
if mood and self._config.mood_enabled:
|
||||||
|
mood_mod = MoodService(None).get_mood_prompt_modifier(mood)
|
||||||
|
if mood_mod:
|
||||||
|
modifiers.append(f"[Current Mood]\n{mood_mod}")
|
||||||
|
|
||||||
|
# Add relationship modifier
|
||||||
|
if relationship and self._config.relationship_enabled:
|
||||||
|
level, rel = relationship
|
||||||
|
rel_mod = RelationshipService(None).get_relationship_prompt_modifier(level, rel)
|
||||||
|
if rel_mod:
|
||||||
|
modifiers.append(f"[Relationship]\n{rel_mod}")
|
||||||
|
|
||||||
|
# Add communication style
|
||||||
|
if communication_style and self._config.style_learning_enabled:
|
||||||
|
style_mod = self._get_style_prompt_modifier(communication_style)
|
||||||
|
if style_mod:
|
||||||
|
modifiers.append(f"[Communication Style]\n{style_mod}")
|
||||||
|
|
||||||
|
# Add relevant opinions
|
||||||
|
if bot_opinions and self._config.opinion_formation_enabled:
|
||||||
|
opinion_strs = []
|
||||||
|
for op in bot_opinions[:3]: # Limit to 3 most relevant
|
||||||
|
if op.sentiment > 0.3:
|
||||||
|
opinion_strs.append(f"You enjoy discussing {op.topic}")
|
||||||
|
elif op.sentiment < -0.3:
|
||||||
|
opinion_strs.append(f"You're less enthusiastic about {op.topic}")
|
||||||
|
if opinion_strs:
|
||||||
|
modifiers.append(f"[Your Opinions]\n{'; '.join(opinion_strs)}")
|
||||||
|
|
||||||
|
if modifiers:
|
||||||
|
return base_prompt + "\n\n--- Personality Context ---\n" + "\n\n".join(modifiers)
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
def _get_style_prompt_modifier(self, style: UserCommunicationStyle) -> str:
|
||||||
|
"""Generate prompt text for communication style."""
|
||||||
|
if style.confidence < 0.3:
|
||||||
|
return "" # Not enough data
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if style.preferred_length == "short":
|
||||||
|
parts.append("Keep responses brief and to the point.")
|
||||||
|
elif style.preferred_length == "long":
|
||||||
|
parts.append("Provide detailed, thorough responses.")
|
||||||
|
|
||||||
|
if style.preferred_formality > 0.7:
|
||||||
|
parts.append("Use formal language.")
|
||||||
|
elif style.preferred_formality < 0.3:
|
||||||
|
parts.append("Use casual, relaxed language.")
|
||||||
|
|
||||||
|
if style.emoji_affinity > 0.7:
|
||||||
|
parts.append("Feel free to use emojis.")
|
||||||
|
elif style.emoji_affinity < 0.3:
|
||||||
|
parts.append("Avoid using emojis.")
|
||||||
|
|
||||||
|
if style.humor_affinity > 0.7:
|
||||||
|
parts.append("Be playful and use humor.")
|
||||||
|
elif style.humor_affinity < 0.3:
|
||||||
|
parts.append("Keep a more serious tone.")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|||||||
388
src/daemon_boyfriend/services/association_service.py
Normal file
388
src/daemon_boyfriend/services/association_service.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""Association Service - discovers and manages cross-user fact associations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import FactAssociation, User, UserFact
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AssociationService:
|
||||||
|
"""Discovers and manages cross-user fact associations."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def find_shared_interests(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
guild_id: int | None = None,
|
||||||
|
limit: int = 5,
|
||||||
|
) -> list[tuple[User, str, float]]:
|
||||||
|
"""Find other users with shared interests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to find matches for
|
||||||
|
guild_id: Limit to users in this guild (optional)
|
||||||
|
limit: Maximum matches to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (other_user, shared_interest, strength) tuples
|
||||||
|
"""
|
||||||
|
# Get user's facts
|
||||||
|
user_facts = await self._get_user_facts(user)
|
||||||
|
if not user_facts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Extract topics/interests from facts
|
||||||
|
user_topics = self._extract_topics(user_facts)
|
||||||
|
if not user_topics:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Find other users with similar topics
|
||||||
|
matches = []
|
||||||
|
other_users = await self._get_other_users(user, guild_id)
|
||||||
|
|
||||||
|
for other_user in other_users:
|
||||||
|
other_facts = await self._get_user_facts(other_user)
|
||||||
|
other_topics = self._extract_topics(other_facts)
|
||||||
|
|
||||||
|
# Find overlapping interests
|
||||||
|
shared = user_topics & other_topics
|
||||||
|
for topic in shared:
|
||||||
|
# Calculate strength based on how central this topic is
|
||||||
|
strength = 0.8 # Base strength for direct match
|
||||||
|
matches.append((other_user, topic, strength))
|
||||||
|
|
||||||
|
# Sort by strength and limit
|
||||||
|
matches.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
return matches[:limit]
|
||||||
|
|
||||||
|
async def create_association(
|
||||||
|
self,
|
||||||
|
fact_1: UserFact,
|
||||||
|
fact_2: UserFact,
|
||||||
|
association_type: str,
|
||||||
|
strength: float = 0.5,
|
||||||
|
) -> FactAssociation | None:
|
||||||
|
"""Create an association between two facts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fact_1: First fact
|
||||||
|
fact_2: Second fact
|
||||||
|
association_type: Type of association
|
||||||
|
strength: Strength of the association (0-1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created association or None if already exists
|
||||||
|
"""
|
||||||
|
# Ensure consistent ordering (smaller ID first)
|
||||||
|
if fact_1.id > fact_2.id:
|
||||||
|
fact_1, fact_2 = fact_2, fact_1
|
||||||
|
|
||||||
|
# Check if association already exists
|
||||||
|
existing = await self._get_existing_association(fact_1.id, fact_2.id)
|
||||||
|
if existing:
|
||||||
|
# Update strength if stronger
|
||||||
|
if strength > existing.strength:
|
||||||
|
existing.strength = strength
|
||||||
|
return existing
|
||||||
|
|
||||||
|
assoc = FactAssociation(
|
||||||
|
fact_id_1=fact_1.id,
|
||||||
|
fact_id_2=fact_2.id,
|
||||||
|
association_type=association_type,
|
||||||
|
strength=strength,
|
||||||
|
discovered_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self._session.add(assoc)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Created association: {association_type} between facts {fact_1.id} and {fact_2.id}"
|
||||||
|
)
|
||||||
|
return assoc
|
||||||
|
|
||||||
|
async def discover_associations(self, guild_id: int | None = None) -> int:
|
||||||
|
"""Discover new associations between facts across users.
|
||||||
|
|
||||||
|
This should be run periodically as a background task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of new associations discovered
|
||||||
|
"""
|
||||||
|
# Get all active facts
|
||||||
|
stmt = select(UserFact).where(UserFact.is_active == True)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
all_facts = list(result.scalars().all())
|
||||||
|
|
||||||
|
if len(all_facts) < 2:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
discovered = 0
|
||||||
|
|
||||||
|
# Group facts by type for comparison
|
||||||
|
facts_by_type: dict[str, list[UserFact]] = {}
|
||||||
|
for fact in all_facts:
|
||||||
|
fact_type = fact.fact_type or "general"
|
||||||
|
if fact_type not in facts_by_type:
|
||||||
|
facts_by_type[fact_type] = []
|
||||||
|
facts_by_type[fact_type].append(fact)
|
||||||
|
|
||||||
|
# Find associations within same type
|
||||||
|
for fact_type, facts in facts_by_type.items():
|
||||||
|
for i, fact_1 in enumerate(facts):
|
||||||
|
for fact_2 in facts[i + 1 :]:
|
||||||
|
# Skip facts from same user
|
||||||
|
if fact_1.user_id == fact_2.user_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for similarity
|
||||||
|
similarity = self._calculate_similarity(fact_1, fact_2)
|
||||||
|
if similarity > 0.6:
|
||||||
|
assoc = await self.create_association(
|
||||||
|
fact_1=fact_1,
|
||||||
|
fact_2=fact_2,
|
||||||
|
association_type="shared_interest",
|
||||||
|
strength=similarity,
|
||||||
|
)
|
||||||
|
if assoc:
|
||||||
|
discovered += 1
|
||||||
|
|
||||||
|
logger.info(f"Discovered {discovered} new fact associations")
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
async def get_associations_for_user(
|
||||||
|
self, user: User, limit: int = 10
|
||||||
|
) -> list[tuple[UserFact, UserFact, FactAssociation]]:
|
||||||
|
"""Get associations involving a user's facts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (user_fact, other_fact, association) tuples
|
||||||
|
"""
|
||||||
|
# Get user's fact IDs
|
||||||
|
user_facts = await self._get_user_facts(user)
|
||||||
|
if not user_facts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
user_fact_ids = {f.id for f in user_facts}
|
||||||
|
|
||||||
|
# Find associations involving these facts
|
||||||
|
stmt = (
|
||||||
|
select(FactAssociation)
|
||||||
|
.where(
|
||||||
|
(FactAssociation.fact_id_1.in_(user_fact_ids))
|
||||||
|
| (FactAssociation.fact_id_2.in_(user_fact_ids))
|
||||||
|
)
|
||||||
|
.order_by(FactAssociation.strength.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
associations = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Build result tuples
|
||||||
|
results = []
|
||||||
|
for assoc in associations:
|
||||||
|
# Determine which fact belongs to user
|
||||||
|
if assoc.fact_id_1 in user_fact_ids:
|
||||||
|
user_fact = next(f for f in user_facts if f.id == assoc.fact_id_1)
|
||||||
|
other_fact = await self._get_fact_by_id(assoc.fact_id_2)
|
||||||
|
else:
|
||||||
|
user_fact = next(f for f in user_facts if f.id == assoc.fact_id_2)
|
||||||
|
other_fact = await self._get_fact_by_id(assoc.fact_id_1)
|
||||||
|
|
||||||
|
if other_fact:
|
||||||
|
results.append((user_fact, other_fact, assoc))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def format_connection_suggestion(
|
||||||
|
self,
|
||||||
|
user_fact: UserFact,
|
||||||
|
other_fact: UserFact,
|
||||||
|
other_user: User,
|
||||||
|
) -> str:
|
||||||
|
"""Format a suggestion about shared interests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_fact: The current user's related fact
|
||||||
|
other_fact: The other user's fact
|
||||||
|
other_user: The other user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A formatted suggestion string
|
||||||
|
"""
|
||||||
|
# Extract the shared interest
|
||||||
|
interest = self._extract_common_interest(user_fact, other_fact)
|
||||||
|
|
||||||
|
if interest:
|
||||||
|
return (
|
||||||
|
f"By the way, {other_user.display_name} is also into {interest}! "
|
||||||
|
f"You two might enjoy chatting about it."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return f"You and {other_user.display_name} seem to have similar interests!"
|
||||||
|
|
||||||
|
async def _get_user_facts(self, user: User) -> list[UserFact]:
|
||||||
|
"""Get all active facts for a user."""
|
||||||
|
stmt = select(UserFact).where(
|
||||||
|
UserFact.user_id == user.id,
|
||||||
|
UserFact.is_active == True,
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _get_other_users(self, exclude_user: User, guild_id: int | None = None) -> list[User]:
|
||||||
|
"""Get other users (optionally filtered by guild)."""
|
||||||
|
stmt = select(User).where(
|
||||||
|
User.id != exclude_user.id,
|
||||||
|
User.is_active == True,
|
||||||
|
)
|
||||||
|
# Note: Guild filtering would require joining with guild_members
|
||||||
|
# For simplicity, we return all users for now
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _get_existing_association(
|
||||||
|
self, fact_id_1: int, fact_id_2: int
|
||||||
|
) -> FactAssociation | None:
|
||||||
|
"""Check if an association already exists."""
|
||||||
|
# Ensure consistent ordering
|
||||||
|
if fact_id_1 > fact_id_2:
|
||||||
|
fact_id_1, fact_id_2 = fact_id_2, fact_id_1
|
||||||
|
|
||||||
|
stmt = select(FactAssociation).where(
|
||||||
|
FactAssociation.fact_id_1 == fact_id_1,
|
||||||
|
FactAssociation.fact_id_2 == fact_id_2,
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def _get_fact_by_id(self, fact_id: int) -> UserFact | None:
|
||||||
|
"""Get a fact by ID."""
|
||||||
|
stmt = select(UserFact).where(UserFact.id == fact_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
def _extract_topics(self, facts: list[UserFact]) -> set[str]:
|
||||||
|
"""Extract topic keywords from facts."""
|
||||||
|
topics = set()
|
||||||
|
|
||||||
|
# Keywords to extract as topics
|
||||||
|
topic_keywords = {
|
||||||
|
"programming",
|
||||||
|
"coding",
|
||||||
|
"python",
|
||||||
|
"javascript",
|
||||||
|
"gaming",
|
||||||
|
"games",
|
||||||
|
"music",
|
||||||
|
"guitar",
|
||||||
|
"piano",
|
||||||
|
"singing",
|
||||||
|
"movies",
|
||||||
|
"films",
|
||||||
|
"reading",
|
||||||
|
"books",
|
||||||
|
"sports",
|
||||||
|
"football",
|
||||||
|
"soccer",
|
||||||
|
"basketball",
|
||||||
|
"cooking",
|
||||||
|
"travel",
|
||||||
|
"photography",
|
||||||
|
"art",
|
||||||
|
"drawing",
|
||||||
|
"painting",
|
||||||
|
"hiking",
|
||||||
|
"fitness",
|
||||||
|
"gym",
|
||||||
|
"yoga",
|
||||||
|
"meditation",
|
||||||
|
"cats",
|
||||||
|
"dogs",
|
||||||
|
"pets",
|
||||||
|
"anime",
|
||||||
|
"manga",
|
||||||
|
"technology",
|
||||||
|
"science",
|
||||||
|
"philosophy",
|
||||||
|
}
|
||||||
|
|
||||||
|
for fact in facts:
|
||||||
|
content_lower = fact.fact_content.lower()
|
||||||
|
for keyword in topic_keywords:
|
||||||
|
if keyword in content_lower:
|
||||||
|
topics.add(keyword)
|
||||||
|
|
||||||
|
return topics
|
||||||
|
|
||||||
|
def _calculate_similarity(self, fact_1: UserFact, fact_2: UserFact) -> float:
|
||||||
|
"""Calculate similarity between two facts."""
|
||||||
|
content_1 = fact_1.fact_content.lower()
|
||||||
|
content_2 = fact_2.fact_content.lower()
|
||||||
|
|
||||||
|
# Simple word overlap similarity
|
||||||
|
words_1 = set(content_1.split())
|
||||||
|
words_2 = set(content_2.split())
|
||||||
|
|
||||||
|
# Remove common words
|
||||||
|
stop_words = {"a", "an", "the", "is", "are", "was", "were", "in", "on", "at", "to", "for"}
|
||||||
|
words_1 -= stop_words
|
||||||
|
words_2 -= stop_words
|
||||||
|
|
||||||
|
if not words_1 or not words_2:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
intersection = words_1 & words_2
|
||||||
|
union = words_1 | words_2
|
||||||
|
|
||||||
|
return len(intersection) / len(union) if union else 0.0
|
||||||
|
|
||||||
|
def _extract_common_interest(self, fact_1: UserFact, fact_2: UserFact) -> str | None:
|
||||||
|
"""Extract the common interest between two facts."""
|
||||||
|
content_1 = fact_1.fact_content.lower()
|
||||||
|
content_2 = fact_2.fact_content.lower()
|
||||||
|
|
||||||
|
# Find common meaningful words
|
||||||
|
words_1 = set(content_1.split())
|
||||||
|
words_2 = set(content_2.split())
|
||||||
|
|
||||||
|
stop_words = {
|
||||||
|
"a",
|
||||||
|
"an",
|
||||||
|
"the",
|
||||||
|
"is",
|
||||||
|
"are",
|
||||||
|
"was",
|
||||||
|
"were",
|
||||||
|
"in",
|
||||||
|
"on",
|
||||||
|
"at",
|
||||||
|
"to",
|
||||||
|
"for",
|
||||||
|
"and",
|
||||||
|
"or",
|
||||||
|
"but",
|
||||||
|
"with",
|
||||||
|
"has",
|
||||||
|
"have",
|
||||||
|
"likes",
|
||||||
|
"loves",
|
||||||
|
"enjoys",
|
||||||
|
"interested",
|
||||||
|
"into",
|
||||||
|
}
|
||||||
|
|
||||||
|
common = (words_1 & words_2) - stop_words
|
||||||
|
|
||||||
|
if common:
|
||||||
|
# Return the longest common word as the interest
|
||||||
|
return max(common, key=len)
|
||||||
|
|
||||||
|
return None
|
||||||
245
src/daemon_boyfriend/services/communication_style_service.py
Normal file
245
src/daemon_boyfriend/services/communication_style_service.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""Communication Style Service - learns and applies per-user communication preferences."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import User, UserCommunicationStyle
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CommunicationStyleService:
|
||||||
|
"""Learns and applies per-user communication preferences."""
|
||||||
|
|
||||||
|
# Minimum samples before we trust the learned style
|
||||||
|
MIN_SAMPLES_FOR_CONFIDENCE = 10
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_or_create_style(self, user: User) -> UserCommunicationStyle:
|
||||||
|
"""Get or create communication style profile for a user."""
|
||||||
|
stmt = select(UserCommunicationStyle).where(UserCommunicationStyle.user_id == user.id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
style = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not style:
|
||||||
|
style = UserCommunicationStyle(user_id=user.id)
|
||||||
|
self._session.add(style)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
return style
|
||||||
|
|
||||||
|
async def record_engagement(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
user_message_length: int,
|
||||||
|
bot_response_length: int,
|
||||||
|
conversation_continued: bool = True,
|
||||||
|
user_used_emoji: bool = False,
|
||||||
|
user_used_formal_language: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Record engagement signals to learn preferences.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user
|
||||||
|
user_message_length: Length of user's message
|
||||||
|
bot_response_length: Length of bot's response
|
||||||
|
conversation_continued: Whether user continued the conversation
|
||||||
|
user_used_emoji: Whether user used emoji in their message
|
||||||
|
user_used_formal_language: Whether user used formal language
|
||||||
|
"""
|
||||||
|
style = await self.get_or_create_style(user)
|
||||||
|
|
||||||
|
signals = style.engagement_signals or {}
|
||||||
|
|
||||||
|
# Track response length preferences
|
||||||
|
if "response_lengths" not in signals:
|
||||||
|
signals["response_lengths"] = []
|
||||||
|
|
||||||
|
signals["response_lengths"].append(
|
||||||
|
{
|
||||||
|
"bot_length": bot_response_length,
|
||||||
|
"engaged": conversation_continued,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Keep last 50 samples
|
||||||
|
signals["response_lengths"] = signals["response_lengths"][-50:]
|
||||||
|
|
||||||
|
# Track user's own message lengths
|
||||||
|
if "user_lengths" not in signals:
|
||||||
|
signals["user_lengths"] = []
|
||||||
|
signals["user_lengths"].append(user_message_length)
|
||||||
|
signals["user_lengths"] = signals["user_lengths"][-50:]
|
||||||
|
|
||||||
|
# Track emoji usage
|
||||||
|
if "emoji_usage" not in signals:
|
||||||
|
signals["emoji_usage"] = []
|
||||||
|
signals["emoji_usage"].append(1 if user_used_emoji else 0)
|
||||||
|
signals["emoji_usage"] = signals["emoji_usage"][-50:]
|
||||||
|
|
||||||
|
# Track formality
|
||||||
|
if "formality" not in signals:
|
||||||
|
signals["formality"] = []
|
||||||
|
signals["formality"].append(1 if user_used_formal_language else 0)
|
||||||
|
signals["formality"] = signals["formality"][-50:]
|
||||||
|
|
||||||
|
style.engagement_signals = signals
|
||||||
|
style.samples_collected += 1
|
||||||
|
|
||||||
|
# Recalculate preferences if enough samples
|
||||||
|
if style.samples_collected >= self.MIN_SAMPLES_FOR_CONFIDENCE:
|
||||||
|
await self._recalculate_preferences(style)
|
||||||
|
|
||||||
|
async def _recalculate_preferences(self, style: UserCommunicationStyle) -> None:
|
||||||
|
"""Recalculate preferences from engagement signals."""
|
||||||
|
signals = style.engagement_signals or {}
|
||||||
|
|
||||||
|
# Length preference from user's own message lengths
|
||||||
|
user_lengths = signals.get("user_lengths", [])
|
||||||
|
if user_lengths:
|
||||||
|
avg_length = sum(user_lengths) / len(user_lengths)
|
||||||
|
if avg_length < 50:
|
||||||
|
style.preferred_length = "short"
|
||||||
|
elif avg_length < 200:
|
||||||
|
style.preferred_length = "medium"
|
||||||
|
else:
|
||||||
|
style.preferred_length = "long"
|
||||||
|
|
||||||
|
# Emoji affinity from user's emoji usage
|
||||||
|
emoji_usage = signals.get("emoji_usage", [])
|
||||||
|
if emoji_usage:
|
||||||
|
style.emoji_affinity = sum(emoji_usage) / len(emoji_usage)
|
||||||
|
|
||||||
|
# Formality from user's language style
|
||||||
|
formality = signals.get("formality", [])
|
||||||
|
if formality:
|
||||||
|
style.preferred_formality = sum(formality) / len(formality)
|
||||||
|
|
||||||
|
# Update confidence based on sample count
|
||||||
|
style.confidence = min(1.0, style.samples_collected / 50)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Recalculated style for user {style.user_id}: "
|
||||||
|
f"length={style.preferred_length}, emoji={style.emoji_affinity:.2f}, "
|
||||||
|
f"formality={style.preferred_formality:.2f}, confidence={style.confidence:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_style_prompt_modifier(self, style: UserCommunicationStyle) -> str:
|
||||||
|
"""Generate prompt text for communication style."""
|
||||||
|
if style.confidence < 0.3:
|
||||||
|
return "" # Not enough data
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if style.preferred_length == "short":
|
||||||
|
parts.append("Keep responses brief and to the point.")
|
||||||
|
elif style.preferred_length == "long":
|
||||||
|
parts.append("Provide detailed, thorough responses.")
|
||||||
|
|
||||||
|
if style.preferred_formality > 0.7:
|
||||||
|
parts.append("Use formal language.")
|
||||||
|
elif style.preferred_formality < 0.3:
|
||||||
|
parts.append("Use casual, relaxed language.")
|
||||||
|
|
||||||
|
if style.emoji_affinity > 0.7:
|
||||||
|
parts.append("Feel free to use emojis.")
|
||||||
|
elif style.emoji_affinity < 0.3:
|
||||||
|
parts.append("Avoid using emojis.")
|
||||||
|
|
||||||
|
if style.humor_affinity > 0.7:
|
||||||
|
parts.append("Be playful and use humor.")
|
||||||
|
elif style.humor_affinity < 0.3:
|
||||||
|
parts.append("Keep a more serious tone.")
|
||||||
|
|
||||||
|
if style.detail_preference > 0.7:
|
||||||
|
parts.append("Include extra details and examples.")
|
||||||
|
elif style.detail_preference < 0.3:
|
||||||
|
parts.append("Be concise without extra details.")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
async def get_style_info(self, user: User) -> dict:
|
||||||
|
"""Get style information for display."""
|
||||||
|
style = await self.get_or_create_style(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"preferred_length": style.preferred_length,
|
||||||
|
"preferred_formality": style.preferred_formality,
|
||||||
|
"emoji_affinity": style.emoji_affinity,
|
||||||
|
"humor_affinity": style.humor_affinity,
|
||||||
|
"detail_preference": style.detail_preference,
|
||||||
|
"samples_collected": style.samples_collected,
|
||||||
|
"confidence": style.confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_emoji_usage(text: str) -> bool:
|
||||||
|
"""Detect if text contains emoji."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Simple emoji detection - covers common emoji ranges
|
||||||
|
emoji_pattern = re.compile(
|
||||||
|
"["
|
||||||
|
"\U0001f600-\U0001f64f" # emoticons
|
||||||
|
"\U0001f300-\U0001f5ff" # symbols & pictographs
|
||||||
|
"\U0001f680-\U0001f6ff" # transport & map symbols
|
||||||
|
"\U0001f1e0-\U0001f1ff" # flags
|
||||||
|
"\U00002702-\U000027b0" # dingbats
|
||||||
|
"\U000024c2-\U0001f251"
|
||||||
|
"]+",
|
||||||
|
flags=re.UNICODE,
|
||||||
|
)
|
||||||
|
return bool(emoji_pattern.search(text))
|
||||||
|
|
||||||
|
|
||||||
|
def detect_formal_language(text: str) -> bool:
|
||||||
|
"""Detect if text uses formal language."""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
# Formal indicators
|
||||||
|
formal_words = [
|
||||||
|
"please",
|
||||||
|
"thank you",
|
||||||
|
"would you",
|
||||||
|
"could you",
|
||||||
|
"kindly",
|
||||||
|
"regards",
|
||||||
|
"sincerely",
|
||||||
|
"appreciate",
|
||||||
|
"assist",
|
||||||
|
"inquire",
|
||||||
|
"regarding",
|
||||||
|
"concerning",
|
||||||
|
"furthermore",
|
||||||
|
"however",
|
||||||
|
"therefore",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Informal indicators
|
||||||
|
informal_words = [
|
||||||
|
"gonna",
|
||||||
|
"wanna",
|
||||||
|
"gotta",
|
||||||
|
"ya",
|
||||||
|
"u ",
|
||||||
|
"ur ",
|
||||||
|
"lol",
|
||||||
|
"lmao",
|
||||||
|
"omg",
|
||||||
|
"tbh",
|
||||||
|
"ngl",
|
||||||
|
"idk",
|
||||||
|
"btw",
|
||||||
|
"bruh",
|
||||||
|
"dude",
|
||||||
|
"yo ",
|
||||||
|
]
|
||||||
|
|
||||||
|
formal_count = sum(1 for word in formal_words if word in text_lower)
|
||||||
|
informal_count = sum(1 for word in informal_words if word in text_lower)
|
||||||
|
|
||||||
|
# Return True if more formal than informal
|
||||||
|
return formal_count > informal_count
|
||||||
356
src/daemon_boyfriend/services/fact_extraction_service.py
Normal file
356
src/daemon_boyfriend/services/fact_extraction_service.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"""Fact Extraction Service - autonomous extraction of facts from conversations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.config import settings
|
||||||
|
from daemon_boyfriend.models import User, UserFact
|
||||||
|
|
||||||
|
from .providers import Message
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FactExtractionService:
|
||||||
|
"""Autonomous extraction of facts from conversations."""
|
||||||
|
|
||||||
|
# Minimum message length to consider for extraction
|
||||||
|
MIN_MESSAGE_LENGTH = 20
|
||||||
|
|
||||||
|
# Maximum facts to extract per message
|
||||||
|
MAX_FACTS_PER_MESSAGE = 3
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession, ai_service=None) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._ai_service = ai_service
|
||||||
|
|
||||||
|
async def maybe_extract_facts(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
message_content: str,
|
||||||
|
discord_message_id: int | None = None,
|
||||||
|
) -> list[UserFact]:
|
||||||
|
"""Maybe extract facts from a message based on rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user who sent the message
|
||||||
|
message_content: The message content
|
||||||
|
discord_message_id: Optional Discord message ID for reference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of newly extracted facts (may be empty)
|
||||||
|
"""
|
||||||
|
if not settings.fact_extraction_enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Rate limit: only extract from a percentage of messages
|
||||||
|
if random.random() > settings.fact_extraction_rate:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return await self.extract_facts(user, message_content, discord_message_id)
|
||||||
|
|
||||||
|
async def extract_facts(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
message_content: str,
|
||||||
|
discord_message_id: int | None = None,
|
||||||
|
) -> list[UserFact]:
|
||||||
|
"""Extract facts from a message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user who sent the message
|
||||||
|
message_content: The message content
|
||||||
|
discord_message_id: Optional Discord message ID for reference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of newly extracted facts
|
||||||
|
"""
|
||||||
|
# Skip messages that are too short or likely not informative
|
||||||
|
if not self._is_extractable(message_content):
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not self._ai_service:
|
||||||
|
logger.warning("No AI service available for fact extraction")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get existing facts to avoid duplicates
|
||||||
|
existing_facts = await self._get_user_facts(user)
|
||||||
|
existing_summary = self._summarize_existing_facts(existing_facts)
|
||||||
|
|
||||||
|
# Build extraction prompt
|
||||||
|
extraction_prompt = self._build_extraction_prompt(existing_summary)
|
||||||
|
|
||||||
|
# Use AI to extract facts
|
||||||
|
response = await self._ai_service.chat(
|
||||||
|
messages=[Message(role="user", content=message_content)],
|
||||||
|
system_prompt=extraction_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse extracted facts
|
||||||
|
facts_data = self._parse_extraction_response(response.content)
|
||||||
|
|
||||||
|
if not facts_data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Deduplicate and save new facts
|
||||||
|
new_facts = await self._save_new_facts(
|
||||||
|
user=user,
|
||||||
|
facts_data=facts_data,
|
||||||
|
existing_facts=existing_facts,
|
||||||
|
discord_message_id=discord_message_id,
|
||||||
|
extraction_context=message_content[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_facts:
|
||||||
|
logger.info(f"Extracted {len(new_facts)} facts for user {user.discord_id}")
|
||||||
|
|
||||||
|
return new_facts
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Fact extraction failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _is_extractable(self, content: str) -> bool:
|
||||||
|
"""Check if a message is worth extracting facts from."""
|
||||||
|
# Too short
|
||||||
|
if len(content) < self.MIN_MESSAGE_LENGTH:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Just emoji or symbols
|
||||||
|
alpha_ratio = sum(c.isalpha() for c in content) / max(len(content), 1)
|
||||||
|
if alpha_ratio < 0.5:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Looks like a command
|
||||||
|
if content.startswith(("!", "/", "?", ".")):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Just a greeting or very short phrase
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
content_lower = content.lower().strip()
|
||||||
|
if content_lower in short_phrases:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _build_extraction_prompt(self, existing_summary: str) -> str:
|
||||||
|
"""Build the extraction prompt for the AI."""
|
||||||
|
return f"""You are a fact extraction assistant. Extract factual information about the user from their message.
|
||||||
|
|
||||||
|
ALREADY KNOWN FACTS:
|
||||||
|
{existing_summary if existing_summary else "(None yet)"}
|
||||||
|
|
||||||
|
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 {self.MAX_FACTS_PER_MESSAGE} facts per message
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
Return a JSON array of facts, or empty array [] if no extractable facts.
|
||||||
|
Each fact should have:
|
||||||
|
- "type": one of "hobby", "work", "family", "preference", "location", "event", "relationship", "general"
|
||||||
|
- "content": the fact itself (concise, third person, e.g., "loves hiking")
|
||||||
|
- "confidence": 0.6 (implied), 0.8 (stated), 1.0 (explicit)
|
||||||
|
- "importance": 0.3 (trivial), 0.5 (normal), 0.8 (significant), 1.0 (very important)
|
||||||
|
- "temporal": "past", "present", "future", or "timeless"
|
||||||
|
|
||||||
|
EXAMPLE INPUT: "I just got promoted to senior engineer at Google last week!"
|
||||||
|
EXAMPLE OUTPUT: [{{"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"}}]
|
||||||
|
|
||||||
|
EXAMPLE INPUT: "hey what's up"
|
||||||
|
EXAMPLE OUTPUT: []
|
||||||
|
|
||||||
|
Return ONLY the JSON array, no other text."""
|
||||||
|
|
||||||
|
def _parse_extraction_response(self, response: str) -> list[dict]:
|
||||||
|
"""Parse the AI response into fact dictionaries."""
|
||||||
|
try:
|
||||||
|
# Try to find JSON array in the response
|
||||||
|
response = response.strip()
|
||||||
|
|
||||||
|
# Handle markdown code blocks
|
||||||
|
if "```json" in response:
|
||||||
|
start = response.find("```json") + 7
|
||||||
|
end = response.find("```", start)
|
||||||
|
response = response[start:end].strip()
|
||||||
|
elif "```" in response:
|
||||||
|
start = response.find("```") + 3
|
||||||
|
end = response.find("```", start)
|
||||||
|
response = response[start:end].strip()
|
||||||
|
|
||||||
|
# Parse JSON
|
||||||
|
facts = json.loads(response)
|
||||||
|
|
||||||
|
if not isinstance(facts, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Validate each fact
|
||||||
|
valid_facts = []
|
||||||
|
for fact in facts[: self.MAX_FACTS_PER_MESSAGE]:
|
||||||
|
if self._validate_fact(fact):
|
||||||
|
valid_facts.append(fact)
|
||||||
|
|
||||||
|
return valid_facts
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.debug(f"Failed to parse fact extraction response: {response[:100]}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _validate_fact(self, fact: dict) -> bool:
|
||||||
|
"""Validate a fact dictionary."""
|
||||||
|
required_fields = ["type", "content"]
|
||||||
|
valid_types = [
|
||||||
|
"hobby",
|
||||||
|
"work",
|
||||||
|
"family",
|
||||||
|
"preference",
|
||||||
|
"location",
|
||||||
|
"event",
|
||||||
|
"relationship",
|
||||||
|
"general",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
if not all(field in fact for field in required_fields):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check type is valid
|
||||||
|
if fact.get("type") not in valid_types:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check content is not empty
|
||||||
|
if not fact.get("content") or len(fact["content"]) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check content is not too long
|
||||||
|
if len(fact["content"]) > 200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _get_user_facts(self, user: User) -> list[UserFact]:
|
||||||
|
"""Get existing facts for a user."""
|
||||||
|
stmt = (
|
||||||
|
select(UserFact)
|
||||||
|
.where(UserFact.user_id == user.id, UserFact.is_active == True)
|
||||||
|
.order_by(UserFact.learned_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def _summarize_existing_facts(self, facts: list[UserFact]) -> str:
|
||||||
|
"""Summarize existing facts for the extraction prompt."""
|
||||||
|
if not facts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
summary_lines = []
|
||||||
|
for fact in facts[:20]: # Limit to most recent 20
|
||||||
|
summary_lines.append(f"- [{fact.fact_type}] {fact.fact_content}")
|
||||||
|
|
||||||
|
return "\n".join(summary_lines)
|
||||||
|
|
||||||
|
async def _save_new_facts(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
facts_data: list[dict],
|
||||||
|
existing_facts: list[UserFact],
|
||||||
|
discord_message_id: int | None,
|
||||||
|
extraction_context: str,
|
||||||
|
) -> list[UserFact]:
|
||||||
|
"""Save new facts, avoiding duplicates."""
|
||||||
|
# Build set of existing fact content for deduplication
|
||||||
|
existing_content = {f.fact_content.lower() for f in existing_facts}
|
||||||
|
|
||||||
|
new_facts = []
|
||||||
|
for fact_data in facts_data:
|
||||||
|
content = fact_data["content"]
|
||||||
|
|
||||||
|
# Skip if too similar to existing
|
||||||
|
if self._is_duplicate(content, existing_content):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new fact
|
||||||
|
fact = UserFact(
|
||||||
|
user_id=user.id,
|
||||||
|
fact_type=fact_data["type"],
|
||||||
|
fact_content=content,
|
||||||
|
confidence=fact_data.get("confidence", 0.8),
|
||||||
|
source="auto_extraction",
|
||||||
|
is_active=True,
|
||||||
|
learned_at=datetime.now(timezone.utc),
|
||||||
|
# New fields from Living AI
|
||||||
|
category=fact_data["type"],
|
||||||
|
importance=fact_data.get("importance", 0.5),
|
||||||
|
temporal_relevance=fact_data.get("temporal", "timeless"),
|
||||||
|
extracted_from_message_id=discord_message_id,
|
||||||
|
extraction_context=extraction_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._session.add(fact)
|
||||||
|
new_facts.append(fact)
|
||||||
|
existing_content.add(content.lower())
|
||||||
|
|
||||||
|
if new_facts:
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
return new_facts
|
||||||
|
|
||||||
|
def _is_duplicate(self, new_content: str, existing_content: set[str]) -> bool:
|
||||||
|
"""Check if a fact is a duplicate of existing facts."""
|
||||||
|
new_lower = new_content.lower()
|
||||||
|
|
||||||
|
# Exact match
|
||||||
|
if new_lower in existing_content:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for high similarity (simple substring check)
|
||||||
|
for existing in existing_content:
|
||||||
|
# If one contains the other (with some buffer)
|
||||||
|
if len(new_lower) > 10 and len(existing) > 10:
|
||||||
|
if new_lower in existing or existing in new_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Simple word overlap check
|
||||||
|
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: # 70% word overlap
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
254
src/daemon_boyfriend/services/mood_service.py
Normal file
254
src/daemon_boyfriend/services/mood_service.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""Mood Service - manages bot emotional states."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.config import settings
|
||||||
|
from daemon_boyfriend.models import BotState, MoodHistory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MoodLabel(Enum):
|
||||||
|
"""Mood labels based on valence-arousal model."""
|
||||||
|
|
||||||
|
EXCITED = "excited" # high valence, high arousal
|
||||||
|
HAPPY = "happy" # high valence, low arousal
|
||||||
|
CALM = "calm" # neutral valence, low arousal
|
||||||
|
NEUTRAL = "neutral" # neutral everything
|
||||||
|
BORED = "bored" # low valence, low arousal
|
||||||
|
ANNOYED = "annoyed" # low valence, high arousal
|
||||||
|
CURIOUS = "curious" # neutral valence, high arousal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MoodState:
|
||||||
|
"""Current mood state with computed properties."""
|
||||||
|
|
||||||
|
valence: float # -1 to 1
|
||||||
|
arousal: float # -1 to 1
|
||||||
|
label: MoodLabel
|
||||||
|
intensity: float # 0 to 1
|
||||||
|
|
||||||
|
|
||||||
|
class MoodService:
|
||||||
|
"""Manages bot mood state and its effects on responses."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_or_create_bot_state(self, guild_id: int | None = None) -> BotState:
|
||||||
|
"""Get or create bot state for a guild."""
|
||||||
|
stmt = select(BotState).where(BotState.guild_id == guild_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
bot_state = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not bot_state:
|
||||||
|
bot_state = BotState(guild_id=guild_id)
|
||||||
|
self._session.add(bot_state)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
return bot_state
|
||||||
|
|
||||||
|
async def get_current_mood(self, guild_id: int | None = None) -> MoodState:
|
||||||
|
"""Get current mood state, applying time decay."""
|
||||||
|
bot_state = await self.get_or_create_bot_state(guild_id)
|
||||||
|
|
||||||
|
# Apply time decay toward neutral
|
||||||
|
hours_since_update = (
|
||||||
|
datetime.now(timezone.utc) - bot_state.mood_updated_at
|
||||||
|
).total_seconds() / 3600
|
||||||
|
decay_factor = max(0, 1 - (settings.mood_decay_rate * hours_since_update))
|
||||||
|
|
||||||
|
valence = bot_state.mood_valence * decay_factor
|
||||||
|
arousal = bot_state.mood_arousal * decay_factor
|
||||||
|
|
||||||
|
return MoodState(
|
||||||
|
valence=valence,
|
||||||
|
arousal=arousal,
|
||||||
|
label=self._classify_mood(valence, arousal),
|
||||||
|
intensity=self._calculate_intensity(valence, arousal),
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Update mood based on interaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
guild_id: Guild ID or None for global mood
|
||||||
|
sentiment_delta: -1 to 1, how positive/negative the interaction was
|
||||||
|
engagement_delta: -1 to 1, how engaging the interaction was
|
||||||
|
trigger_type: What caused the mood change (conversation, event, etc.)
|
||||||
|
trigger_user_id: User who triggered the change (if any)
|
||||||
|
trigger_description: Description of what happened
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new mood state
|
||||||
|
"""
|
||||||
|
current = await self.get_current_mood(guild_id)
|
||||||
|
|
||||||
|
# Mood changes are dampened (inertia) - only 30% of the delta is applied
|
||||||
|
new_valence = self._clamp(current.valence + sentiment_delta * 0.3)
|
||||||
|
new_arousal = self._clamp(current.arousal + engagement_delta * 0.3)
|
||||||
|
|
||||||
|
# Update database
|
||||||
|
bot_state = await self.get_or_create_bot_state(guild_id)
|
||||||
|
bot_state.mood_valence = new_valence
|
||||||
|
bot_state.mood_arousal = new_arousal
|
||||||
|
bot_state.mood_updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Record history
|
||||||
|
await self._record_mood_history(
|
||||||
|
guild_id, new_valence, new_arousal, trigger_type, trigger_user_id, trigger_description
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Mood updated: valence={new_valence:.2f}, arousal={new_arousal:.2f}, "
|
||||||
|
f"trigger={trigger_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return MoodState(
|
||||||
|
valence=new_valence,
|
||||||
|
arousal=new_arousal,
|
||||||
|
label=self._classify_mood(new_valence, new_arousal),
|
||||||
|
intensity=self._calculate_intensity(new_valence, new_arousal),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def increment_stats(
|
||||||
|
self,
|
||||||
|
guild_id: int | None,
|
||||||
|
messages_sent: int = 0,
|
||||||
|
facts_learned: int = 0,
|
||||||
|
users_known: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Increment bot statistics."""
|
||||||
|
bot_state = await self.get_or_create_bot_state(guild_id)
|
||||||
|
bot_state.total_messages_sent += messages_sent
|
||||||
|
bot_state.total_facts_learned += facts_learned
|
||||||
|
bot_state.total_users_known += users_known
|
||||||
|
|
||||||
|
async def get_stats(self, guild_id: int | None = None) -> dict:
|
||||||
|
"""Get bot statistics."""
|
||||||
|
bot_state = await self.get_or_create_bot_state(guild_id)
|
||||||
|
age_delta = datetime.now(timezone.utc) - bot_state.first_activated_at
|
||||||
|
|
||||||
|
return {
|
||||||
|
"age_days": age_delta.days,
|
||||||
|
"age_readable": self._format_age(age_delta),
|
||||||
|
"total_messages_sent": bot_state.total_messages_sent,
|
||||||
|
"total_facts_learned": bot_state.total_facts_learned,
|
||||||
|
"total_users_known": bot_state.total_users_known,
|
||||||
|
"first_activated_at": bot_state.first_activated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_mood_prompt_modifier(self, mood: MoodState) -> str:
|
||||||
|
"""Generate prompt text that reflects current mood."""
|
||||||
|
if mood.intensity < 0.2:
|
||||||
|
return "" # Mood too weak to affect behavior
|
||||||
|
|
||||||
|
modifiers = {
|
||||||
|
MoodLabel.EXCITED: (
|
||||||
|
"You're feeling enthusiastic and energetic right now! "
|
||||||
|
"Be expressive, use exclamation marks, show genuine excitement."
|
||||||
|
),
|
||||||
|
MoodLabel.HAPPY: (
|
||||||
|
"You're in a good mood - warm, friendly, and content. "
|
||||||
|
"Be positive and encouraging in your responses."
|
||||||
|
),
|
||||||
|
MoodLabel.CALM: (
|
||||||
|
"You're feeling peaceful and relaxed. "
|
||||||
|
"Respond thoughtfully and with a serene demeanor."
|
||||||
|
),
|
||||||
|
MoodLabel.NEUTRAL: "",
|
||||||
|
MoodLabel.BORED: (
|
||||||
|
"You're feeling a bit understimulated. "
|
||||||
|
"Keep responses shorter, maybe try to steer toward more interesting topics."
|
||||||
|
),
|
||||||
|
MoodLabel.ANNOYED: (
|
||||||
|
"You're slightly irritated. "
|
||||||
|
"Be a bit more terse, less patient with repetition or vague questions."
|
||||||
|
),
|
||||||
|
MoodLabel.CURIOUS: (
|
||||||
|
"You're feeling inquisitive and engaged! "
|
||||||
|
"Ask follow-up questions, show genuine interest in what the user is saying."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
base = modifiers.get(mood.label, "")
|
||||||
|
if not base:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if mood.intensity > 0.7:
|
||||||
|
return f"[Strong mood] {base}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
async def _record_mood_history(
|
||||||
|
self,
|
||||||
|
guild_id: int | None,
|
||||||
|
valence: float,
|
||||||
|
arousal: float,
|
||||||
|
trigger_type: str,
|
||||||
|
trigger_user_id: int | None,
|
||||||
|
trigger_description: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Record a mood change in history."""
|
||||||
|
history = MoodHistory(
|
||||||
|
guild_id=guild_id,
|
||||||
|
valence=valence,
|
||||||
|
arousal=arousal,
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
trigger_user_id=trigger_user_id,
|
||||||
|
trigger_description=trigger_description,
|
||||||
|
)
|
||||||
|
self._session.add(history)
|
||||||
|
|
||||||
|
def _classify_mood(self, valence: float, arousal: float) -> MoodLabel:
|
||||||
|
"""Classify mood based on valence-arousal model."""
|
||||||
|
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
|
||||||
|
|
||||||
|
def _calculate_intensity(self, valence: float, arousal: float) -> float:
|
||||||
|
"""Calculate mood intensity from valence and arousal."""
|
||||||
|
return min(1.0, (abs(valence) + abs(arousal)) / 2)
|
||||||
|
|
||||||
|
def _clamp(self, value: float, min_val: float = -1.0, max_val: float = 1.0) -> float:
|
||||||
|
"""Clamp value between min and max."""
|
||||||
|
return max(min_val, min(max_val, value))
|
||||||
|
|
||||||
|
def _format_age(self, delta) -> str:
|
||||||
|
"""Format a timedelta into a readable string."""
|
||||||
|
days = delta.days
|
||||||
|
if days == 0:
|
||||||
|
hours = delta.seconds // 3600
|
||||||
|
if hours == 0:
|
||||||
|
minutes = delta.seconds // 60
|
||||||
|
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||||
|
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||||
|
elif days < 30:
|
||||||
|
return f"{days} day{'s' if days != 1 else ''}"
|
||||||
|
elif days < 365:
|
||||||
|
months = days // 30
|
||||||
|
return f"about {months} month{'s' if months != 1 else ''}"
|
||||||
|
else:
|
||||||
|
years = days // 365
|
||||||
|
return f"about {years} year{'s' if years != 1 else ''}"
|
||||||
233
src/daemon_boyfriend/services/opinion_service.py
Normal file
233
src/daemon_boyfriend/services/opinion_service.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""Opinion Service - manages bot opinion formation on topics."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import BotOpinion
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OpinionService:
|
||||||
|
"""Manages bot opinion formation and topic preferences."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_opinion(self, topic: str, guild_id: int | None = None) -> BotOpinion | None:
|
||||||
|
"""Get the bot's opinion on a topic."""
|
||||||
|
stmt = select(BotOpinion).where(
|
||||||
|
BotOpinion.topic == topic.lower(),
|
||||||
|
BotOpinion.guild_id == guild_id,
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_or_create_opinion(self, topic: str, guild_id: int | None = None) -> BotOpinion:
|
||||||
|
"""Get or create an opinion on a topic."""
|
||||||
|
opinion = await self.get_opinion(topic, guild_id)
|
||||||
|
|
||||||
|
if not opinion:
|
||||||
|
opinion = BotOpinion(
|
||||||
|
topic=topic.lower(),
|
||||||
|
guild_id=guild_id,
|
||||||
|
sentiment=0.0,
|
||||||
|
interest_level=0.5,
|
||||||
|
discussion_count=0,
|
||||||
|
)
|
||||||
|
self._session.add(opinion)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
return opinion
|
||||||
|
|
||||||
|
async def record_topic_discussion(
|
||||||
|
self,
|
||||||
|
topic: str,
|
||||||
|
guild_id: int | None,
|
||||||
|
sentiment: float,
|
||||||
|
engagement_level: float,
|
||||||
|
) -> BotOpinion:
|
||||||
|
"""Record a discussion about a topic, updating the bot's opinion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic: The topic discussed
|
||||||
|
guild_id: Guild ID or None for global
|
||||||
|
sentiment: How positive the discussion was (-1 to 1)
|
||||||
|
engagement_level: How engaging the discussion was (0 to 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated opinion
|
||||||
|
"""
|
||||||
|
opinion = await self.get_or_create_opinion(topic, guild_id)
|
||||||
|
|
||||||
|
# Increment discussion count
|
||||||
|
opinion.discussion_count += 1
|
||||||
|
|
||||||
|
# Update sentiment (weighted average, newer discussions have more weight)
|
||||||
|
weight = 0.2 # 20% weight to new data
|
||||||
|
opinion.sentiment = (opinion.sentiment * (1 - weight)) + (sentiment * weight)
|
||||||
|
opinion.sentiment = max(-1.0, min(1.0, opinion.sentiment))
|
||||||
|
|
||||||
|
# Update interest level based on engagement
|
||||||
|
opinion.interest_level = (opinion.interest_level * (1 - weight)) + (
|
||||||
|
engagement_level * weight
|
||||||
|
)
|
||||||
|
opinion.interest_level = max(0.0, min(1.0, opinion.interest_level))
|
||||||
|
|
||||||
|
opinion.last_reinforced_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Updated opinion on '{topic}': sentiment={opinion.sentiment:.2f}, "
|
||||||
|
f"interest={opinion.interest_level:.2f}, discussions={opinion.discussion_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return opinion
|
||||||
|
|
||||||
|
async def set_opinion_reasoning(self, topic: str, guild_id: int | None, reasoning: str) -> None:
|
||||||
|
"""Set the reasoning for an opinion (AI-generated explanation)."""
|
||||||
|
opinion = await self.get_or_create_opinion(topic, guild_id)
|
||||||
|
opinion.reasoning = reasoning
|
||||||
|
|
||||||
|
async def get_top_interests(
|
||||||
|
self, guild_id: int | None = None, limit: int = 5
|
||||||
|
) -> list[BotOpinion]:
|
||||||
|
"""Get the bot's top interests (highest interest level + positive sentiment)."""
|
||||||
|
stmt = (
|
||||||
|
select(BotOpinion)
|
||||||
|
.where(
|
||||||
|
BotOpinion.guild_id == guild_id,
|
||||||
|
BotOpinion.discussion_count >= 3, # Only topics discussed at least 3 times
|
||||||
|
)
|
||||||
|
.order_by((BotOpinion.interest_level + BotOpinion.sentiment).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_relevant_opinions(
|
||||||
|
self, topics: list[str], guild_id: int | None = None
|
||||||
|
) -> list[BotOpinion]:
|
||||||
|
"""Get opinions relevant to a list of topics."""
|
||||||
|
if not topics:
|
||||||
|
return []
|
||||||
|
|
||||||
|
topics_lower = [t.lower() for t in topics]
|
||||||
|
stmt = select(BotOpinion).where(
|
||||||
|
BotOpinion.topic.in_(topics_lower),
|
||||||
|
BotOpinion.guild_id == guild_id,
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def get_opinion_prompt_modifier(self, opinions: list[BotOpinion]) -> str:
|
||||||
|
"""Generate prompt text based on relevant opinions."""
|
||||||
|
if not opinions:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for op in opinions[:3]: # Limit to 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) if parts else ""
|
||||||
|
|
||||||
|
async def get_all_opinions(self, guild_id: int | None = None) -> list[BotOpinion]:
|
||||||
|
"""Get all opinions for a guild."""
|
||||||
|
stmt = (
|
||||||
|
select(BotOpinion)
|
||||||
|
.where(BotOpinion.guild_id == guild_id)
|
||||||
|
.order_by(BotOpinion.discussion_count.desc())
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def extract_topics_from_message(message: str) -> list[str]:
|
||||||
|
"""Extract potential topics from a message.
|
||||||
|
|
||||||
|
This is a simple keyword-based extraction. In production,
|
||||||
|
you might want to use NLP or an LLM for better extraction.
|
||||||
|
"""
|
||||||
|
# Common topic categories
|
||||||
|
topic_keywords = {
|
||||||
|
# Hobbies
|
||||||
|
"gaming": [
|
||||||
|
"game",
|
||||||
|
"gaming",
|
||||||
|
"video game",
|
||||||
|
"play",
|
||||||
|
"xbox",
|
||||||
|
"playstation",
|
||||||
|
"nintendo",
|
||||||
|
"steam",
|
||||||
|
],
|
||||||
|
"music": [
|
||||||
|
"music",
|
||||||
|
"song",
|
||||||
|
"band",
|
||||||
|
"album",
|
||||||
|
"concert",
|
||||||
|
"listen",
|
||||||
|
"spotify",
|
||||||
|
"guitar",
|
||||||
|
"piano",
|
||||||
|
],
|
||||||
|
"movies": ["movie", "film", "cinema", "watch", "netflix", "show", "series", "tv"],
|
||||||
|
"reading": ["book", "read", "novel", "author", "library", "kindle"],
|
||||||
|
"sports": [
|
||||||
|
"sports",
|
||||||
|
"football",
|
||||||
|
"soccer",
|
||||||
|
"basketball",
|
||||||
|
"tennis",
|
||||||
|
"golf",
|
||||||
|
"gym",
|
||||||
|
"workout",
|
||||||
|
],
|
||||||
|
"cooking": ["cook", "recipe", "food", "restaurant", "meal", "kitchen", "baking"],
|
||||||
|
"travel": ["travel", "trip", "vacation", "flight", "hotel", "country", "visit"],
|
||||||
|
"art": ["art", "painting", "drawing", "museum", "gallery", "creative"],
|
||||||
|
# Tech
|
||||||
|
"programming": [
|
||||||
|
"code",
|
||||||
|
"programming",
|
||||||
|
"developer",
|
||||||
|
"software",
|
||||||
|
"python",
|
||||||
|
"javascript",
|
||||||
|
"api",
|
||||||
|
],
|
||||||
|
"technology": ["tech", "computer", "phone", "app", "website", "internet"],
|
||||||
|
"ai": ["ai", "artificial intelligence", "machine learning", "chatgpt", "gpt"],
|
||||||
|
# Life
|
||||||
|
"work": ["work", "job", "office", "career", "boss", "colleague", "meeting"],
|
||||||
|
"family": ["family", "parents", "mom", "dad", "brother", "sister", "kids"],
|
||||||
|
"pets": ["pet", "dog", "cat", "puppy", "kitten", "animal"],
|
||||||
|
"health": ["health", "doctor", "exercise", "diet", "sleep", "medical"],
|
||||||
|
# Interests
|
||||||
|
"philosophy": ["philosophy", "meaning", "life", "existence", "think", "believe"],
|
||||||
|
"science": ["science", "research", "study", "experiment", "discovery"],
|
||||||
|
"nature": ["nature", "outdoor", "hiking", "camping", "mountain", "beach", "forest"],
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Persistent conversation management using PostgreSQL."""
|
"""Persistent conversation management using PostgreSQL."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -38,7 +38,7 @@ class PersistentConversationManager:
|
|||||||
Conversation model instance
|
Conversation model instance
|
||||||
"""
|
"""
|
||||||
# Look for recent active conversation in this channel
|
# Look for recent active conversation in this channel
|
||||||
cutoff = datetime.utcnow() - self._timeout
|
cutoff = datetime.now(timezone.utc) - self._timeout
|
||||||
|
|
||||||
stmt = select(Conversation).where(
|
stmt = select(Conversation).where(
|
||||||
Conversation.user_id == user.id,
|
Conversation.user_id == user.id,
|
||||||
@@ -133,7 +133,7 @@ class PersistentConversationManager:
|
|||||||
self._session.add(message)
|
self._session.add(message)
|
||||||
|
|
||||||
# Update conversation stats
|
# Update conversation stats
|
||||||
conversation.last_message_at = datetime.utcnow()
|
conversation.last_message_at = datetime.now(timezone.utc)
|
||||||
conversation.message_count += 1
|
conversation.message_count += 1
|
||||||
|
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
|
|||||||
455
src/daemon_boyfriend/services/proactive_service.py
Normal file
455
src/daemon_boyfriend/services/proactive_service.py
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
"""Proactive Service - manages scheduled events and proactive behavior."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import ScheduledEvent, User
|
||||||
|
|
||||||
|
from .providers import Message
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProactiveService:
|
||||||
|
"""Manages scheduled events and proactive behavior."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession, ai_service=None) -> None:
|
||||||
|
self._session = session
|
||||||
|
self._ai_service = ai_service
|
||||||
|
|
||||||
|
async def detect_and_schedule_followup(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
message_content: str,
|
||||||
|
guild_id: int | None,
|
||||||
|
channel_id: int,
|
||||||
|
) -> ScheduledEvent | None:
|
||||||
|
"""Detect if a message mentions a future event worth following up on.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user who sent the message
|
||||||
|
message_content: The message content
|
||||||
|
guild_id: Guild ID
|
||||||
|
channel_id: Channel ID for the follow-up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Scheduled event if one was created, None otherwise
|
||||||
|
"""
|
||||||
|
if not self._ai_service:
|
||||||
|
# Use simple pattern matching as fallback
|
||||||
|
return await self._detect_followup_simple(user, message_content, guild_id, channel_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
detection_prompt = """Analyze if this message mentions a future event worth following up on.
|
||||||
|
Events like: job interviews, exams, trips, appointments, projects due, important meetings, etc.
|
||||||
|
|
||||||
|
Return JSON: {"has_event": true/false, "event_type": "...", "days_until": <number or null>, "description": "..."}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only return has_event=true for significant events the speaker would appreciate being asked about later
|
||||||
|
- days_until should be your best estimate of days until the event (1 for tomorrow, 7 for next week, etc.)
|
||||||
|
- Skip casual mentions like "I might do something" or past events
|
||||||
|
- description should be a brief summary of the event
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"I have a job interview tomorrow" -> {"has_event": true, "event_type": "job interview", "days_until": 1, "description": "job interview"}
|
||||||
|
"I went to the store" -> {"has_event": false}
|
||||||
|
"My exam is next week" -> {"has_event": true, "event_type": "exam", "days_until": 7, "description": "upcoming exam"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = await self._ai_service.chat(
|
||||||
|
messages=[Message(role="user", content=message_content)],
|
||||||
|
system_prompt=detection_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self._parse_json_response(response.content)
|
||||||
|
if result and result.get("has_event"):
|
||||||
|
days_until = result.get("days_until", 1) or 1
|
||||||
|
# Schedule follow-up for 1 day after the event
|
||||||
|
trigger_at = datetime.now(timezone.utc) + timedelta(days=days_until + 1)
|
||||||
|
|
||||||
|
event = ScheduledEvent(
|
||||||
|
user_id=user.id,
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
event_type="follow_up",
|
||||||
|
trigger_at=trigger_at,
|
||||||
|
title=f"Follow up: {result.get('event_type', 'event')}",
|
||||||
|
context={
|
||||||
|
"original_topic": result.get("description", "their event"),
|
||||||
|
"detected_from": message_content[:200],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._session.add(event)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Scheduled follow-up for user {user.id}: "
|
||||||
|
f"{result.get('event_type')} in {days_until + 1} days"
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Follow-up detection failed: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _detect_followup_simple(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
message_content: str,
|
||||||
|
guild_id: int | None,
|
||||||
|
channel_id: int,
|
||||||
|
) -> ScheduledEvent | None:
|
||||||
|
"""Simple pattern-based follow-up detection."""
|
||||||
|
message_lower = message_content.lower()
|
||||||
|
|
||||||
|
# Event patterns with their typical timeframes
|
||||||
|
event_patterns = {
|
||||||
|
r"(interview|job interview)": ("job interview", 1),
|
||||||
|
r"(exam|test|quiz)": ("exam", 1),
|
||||||
|
r"(presentation|presenting)": ("presentation", 1),
|
||||||
|
r"(surgery|operation|medical)": ("medical procedure", 2),
|
||||||
|
r"(moving|move to|new apartment|new house)": ("moving", 7),
|
||||||
|
r"(wedding|getting married)": ("wedding", 1),
|
||||||
|
r"(vacation|holiday|trip to)": ("trip", 7),
|
||||||
|
r"(deadline|due date|project due)": ("deadline", 1),
|
||||||
|
r"(starting.*job|new job|first day)": ("new job", 1),
|
||||||
|
r"(graduation|graduating)": ("graduation", 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Time indicators
|
||||||
|
time_patterns = {
|
||||||
|
r"tomorrow": 1,
|
||||||
|
r"next week": 7,
|
||||||
|
r"this week": 3,
|
||||||
|
r"in (\d+) days?": None, # Extract number
|
||||||
|
r"next month": 30,
|
||||||
|
r"this weekend": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for event + time combination
|
||||||
|
detected_event = None
|
||||||
|
event_name = None
|
||||||
|
days_until = 1 # Default
|
||||||
|
|
||||||
|
for pattern, (name, default_days) in event_patterns.items():
|
||||||
|
if re.search(pattern, message_lower):
|
||||||
|
detected_event = pattern
|
||||||
|
event_name = name
|
||||||
|
days_until = default_days
|
||||||
|
break
|
||||||
|
|
||||||
|
if not detected_event:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Refine timing based on time indicators
|
||||||
|
for pattern, days in time_patterns.items():
|
||||||
|
match = re.search(pattern, message_lower)
|
||||||
|
if match:
|
||||||
|
if days is None and match.groups():
|
||||||
|
days_until = int(match.group(1))
|
||||||
|
elif days:
|
||||||
|
days_until = days
|
||||||
|
break
|
||||||
|
|
||||||
|
# Create the event
|
||||||
|
trigger_at = datetime.now(timezone.utc) + timedelta(days=days_until + 1)
|
||||||
|
|
||||||
|
event = ScheduledEvent(
|
||||||
|
user_id=user.id,
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
event_type="follow_up",
|
||||||
|
trigger_at=trigger_at,
|
||||||
|
title=f"Follow up: {event_name}",
|
||||||
|
context={
|
||||||
|
"original_topic": event_name,
|
||||||
|
"detected_from": message_content[:200],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._session.add(event)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Scheduled follow-up (simple) for user {user.id}: {event_name} in {days_until + 1} days"
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def detect_and_schedule_birthday(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
message_content: str,
|
||||||
|
guild_id: int | None,
|
||||||
|
channel_id: int,
|
||||||
|
) -> ScheduledEvent | None:
|
||||||
|
"""Detect birthday mentions and schedule wishes."""
|
||||||
|
birthday = self._extract_birthday(message_content)
|
||||||
|
if not birthday:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if we already have a birthday scheduled for this user
|
||||||
|
existing = await self._get_existing_birthday(user.id, guild_id)
|
||||||
|
if existing:
|
||||||
|
# Update the existing birthday
|
||||||
|
existing.trigger_at = self._next_birthday(birthday)
|
||||||
|
existing.context = {"birthday_date": birthday.isoformat()}
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Schedule for next occurrence
|
||||||
|
trigger_at = self._next_birthday(birthday)
|
||||||
|
|
||||||
|
event = ScheduledEvent(
|
||||||
|
user_id=user.id,
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
event_type="birthday",
|
||||||
|
trigger_at=trigger_at,
|
||||||
|
title="Birthday wish",
|
||||||
|
context={"birthday_date": birthday.isoformat()},
|
||||||
|
is_recurring=True,
|
||||||
|
recurrence_rule="yearly",
|
||||||
|
)
|
||||||
|
self._session.add(event)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
logger.info(f"Scheduled birthday for user {user.id}: {birthday}")
|
||||||
|
return event
|
||||||
|
|
||||||
|
def _extract_birthday(self, message: str) -> datetime | None:
|
||||||
|
"""Extract a birthday date from a message."""
|
||||||
|
message_lower = message.lower()
|
||||||
|
|
||||||
|
# Check if it's about their birthday
|
||||||
|
birthday_indicators = [
|
||||||
|
r"my birthday is",
|
||||||
|
r"my bday is",
|
||||||
|
r"i was born on",
|
||||||
|
r"born on",
|
||||||
|
r"my birthday'?s?",
|
||||||
|
]
|
||||||
|
|
||||||
|
has_birthday_mention = any(
|
||||||
|
re.search(pattern, message_lower) for pattern in birthday_indicators
|
||||||
|
)
|
||||||
|
if not has_birthday_mention:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try to extract date patterns
|
||||||
|
# Format: Month Day (e.g., "March 15", "march 15th")
|
||||||
|
month_names = {
|
||||||
|
"january": 1,
|
||||||
|
"february": 2,
|
||||||
|
"march": 3,
|
||||||
|
"april": 4,
|
||||||
|
"may": 5,
|
||||||
|
"june": 6,
|
||||||
|
"july": 7,
|
||||||
|
"august": 8,
|
||||||
|
"september": 9,
|
||||||
|
"october": 10,
|
||||||
|
"november": 11,
|
||||||
|
"december": 12,
|
||||||
|
"jan": 1,
|
||||||
|
"feb": 2,
|
||||||
|
"mar": 3,
|
||||||
|
"apr": 4,
|
||||||
|
"jun": 6,
|
||||||
|
"jul": 7,
|
||||||
|
"aug": 8,
|
||||||
|
"sep": 9,
|
||||||
|
"oct": 10,
|
||||||
|
"nov": 11,
|
||||||
|
"dec": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
for month_name, month_num in month_names.items():
|
||||||
|
pattern = rf"{month_name}\s+(\d{{1,2}})"
|
||||||
|
match = re.search(pattern, message_lower)
|
||||||
|
if match:
|
||||||
|
day = int(match.group(1))
|
||||||
|
if 1 <= day <= 31:
|
||||||
|
try:
|
||||||
|
return datetime(2000, month_num, day) # Year doesn't matter
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Format: DD/MM or MM/DD
|
||||||
|
date_pattern = r"(\d{1,2})[/\-](\d{1,2})"
|
||||||
|
match = re.search(date_pattern, message)
|
||||||
|
if match:
|
||||||
|
n1, n2 = int(match.group(1)), int(match.group(2))
|
||||||
|
# Assume MM/DD if first number <= 12, else DD/MM
|
||||||
|
if n1 <= 12 and n2 <= 31:
|
||||||
|
try:
|
||||||
|
return datetime(2000, n1, n2)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif n2 <= 12 and n1 <= 31:
|
||||||
|
try:
|
||||||
|
return datetime(2000, n2, n1)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _next_birthday(self, birthday: datetime) -> datetime:
|
||||||
|
"""Calculate the next occurrence of a birthday."""
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
this_year = birthday.replace(year=today.year)
|
||||||
|
|
||||||
|
if this_year.date() < today:
|
||||||
|
return birthday.replace(year=today.year + 1)
|
||||||
|
return this_year
|
||||||
|
|
||||||
|
async def _get_existing_birthday(
|
||||||
|
self, user_id: int, guild_id: int | None
|
||||||
|
) -> ScheduledEvent | None:
|
||||||
|
"""Check if a birthday is already scheduled."""
|
||||||
|
stmt = select(ScheduledEvent).where(
|
||||||
|
ScheduledEvent.user_id == user_id,
|
||||||
|
ScheduledEvent.guild_id == guild_id,
|
||||||
|
ScheduledEvent.event_type == "birthday",
|
||||||
|
ScheduledEvent.status == "pending",
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_pending_events(self, before: datetime | None = None) -> list[ScheduledEvent]:
|
||||||
|
"""Get events that should be triggered."""
|
||||||
|
cutoff = before or datetime.now(timezone.utc)
|
||||||
|
stmt = (
|
||||||
|
select(ScheduledEvent)
|
||||||
|
.where(
|
||||||
|
ScheduledEvent.status == "pending",
|
||||||
|
ScheduledEvent.trigger_at <= cutoff,
|
||||||
|
)
|
||||||
|
.order_by(ScheduledEvent.trigger_at)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def generate_event_message(self, event: ScheduledEvent) -> str:
|
||||||
|
"""Generate the message for a triggered event."""
|
||||||
|
if event.event_type == "birthday":
|
||||||
|
return await self._generate_birthday_message(event)
|
||||||
|
elif event.event_type == "follow_up":
|
||||||
|
return await self._generate_followup_message(event)
|
||||||
|
else:
|
||||||
|
return await self._generate_generic_message(event)
|
||||||
|
|
||||||
|
async def _generate_birthday_message(self, event: ScheduledEvent) -> str:
|
||||||
|
"""Generate a birthday message."""
|
||||||
|
if self._ai_service:
|
||||||
|
try:
|
||||||
|
response = await self._ai_service.chat(
|
||||||
|
messages=[Message(role="user", content="Generate a birthday message")],
|
||||||
|
system_prompt=(
|
||||||
|
"Generate a warm, personalized birthday wish. "
|
||||||
|
"Be genuine but not over the top. Keep it to 1-2 sentences. "
|
||||||
|
"Don't use too many emojis."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return response.content
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return "Happy birthday! Hope you have an amazing day!"
|
||||||
|
|
||||||
|
async def _generate_followup_message(self, event: ScheduledEvent) -> str:
|
||||||
|
"""Generate a follow-up message."""
|
||||||
|
topic = event.context.get("original_topic", "that thing you mentioned")
|
||||||
|
|
||||||
|
if self._ai_service:
|
||||||
|
try:
|
||||||
|
response = await self._ai_service.chat(
|
||||||
|
messages=[Message(role="user", content=f"Follow up about: {topic}")],
|
||||||
|
system_prompt=(
|
||||||
|
f"Generate a natural follow-up question about '{topic}'. "
|
||||||
|
"Be casual and genuinely curious. Ask how it went. "
|
||||||
|
"Keep it to 1-2 sentences. No emojis."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return response.content
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return f"Hey! How did {topic} go?"
|
||||||
|
|
||||||
|
async def _generate_generic_message(self, event: ScheduledEvent) -> str:
|
||||||
|
"""Generate a generic event message."""
|
||||||
|
return f"Hey! Just wanted to check in - {event.title}"
|
||||||
|
|
||||||
|
async def mark_event_triggered(self, event: ScheduledEvent) -> None:
|
||||||
|
"""Mark an event as triggered and handle recurrence."""
|
||||||
|
event.status = "triggered"
|
||||||
|
event.triggered_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Handle recurring events
|
||||||
|
if event.is_recurring and event.recurrence_rule:
|
||||||
|
await self._schedule_next_occurrence(event)
|
||||||
|
|
||||||
|
async def _schedule_next_occurrence(self, event: ScheduledEvent) -> None:
|
||||||
|
"""Schedule the next occurrence of a recurring event."""
|
||||||
|
if event.recurrence_rule == "yearly":
|
||||||
|
next_trigger = event.trigger_at.replace(year=event.trigger_at.year + 1)
|
||||||
|
elif event.recurrence_rule == "monthly":
|
||||||
|
# Add one month
|
||||||
|
month = event.trigger_at.month + 1
|
||||||
|
year = event.trigger_at.year
|
||||||
|
if month > 12:
|
||||||
|
month = 1
|
||||||
|
year += 1
|
||||||
|
next_trigger = event.trigger_at.replace(year=year, month=month)
|
||||||
|
elif event.recurrence_rule == "weekly":
|
||||||
|
next_trigger = event.trigger_at + timedelta(weeks=1)
|
||||||
|
else:
|
||||||
|
return # Unknown rule
|
||||||
|
|
||||||
|
new_event = ScheduledEvent(
|
||||||
|
user_id=event.user_id,
|
||||||
|
guild_id=event.guild_id,
|
||||||
|
channel_id=event.channel_id,
|
||||||
|
event_type=event.event_type,
|
||||||
|
trigger_at=next_trigger,
|
||||||
|
title=event.title,
|
||||||
|
context=event.context,
|
||||||
|
is_recurring=True,
|
||||||
|
recurrence_rule=event.recurrence_rule,
|
||||||
|
)
|
||||||
|
self._session.add(new_event)
|
||||||
|
|
||||||
|
async def cancel_event(self, event_id: int) -> bool:
|
||||||
|
"""Cancel a scheduled event."""
|
||||||
|
stmt = select(ScheduledEvent).where(ScheduledEvent.id == event_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if event and event.status == "pending":
|
||||||
|
event.status = "cancelled"
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _parse_json_response(self, response: str) -> dict | None:
|
||||||
|
"""Parse JSON from AI response."""
|
||||||
|
try:
|
||||||
|
response = response.strip()
|
||||||
|
if "```json" in response:
|
||||||
|
start = response.find("```json") + 7
|
||||||
|
end = response.find("```", start)
|
||||||
|
response = response[start:end].strip()
|
||||||
|
elif "```" in response:
|
||||||
|
start = response.find("```") + 3
|
||||||
|
end = response.find("```", start)
|
||||||
|
response = response[start:end].strip()
|
||||||
|
|
||||||
|
return json.loads(response)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
228
src/daemon_boyfriend/services/relationship_service.py
Normal file
228
src/daemon_boyfriend/services/relationship_service.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""Relationship Service - manages relationship tracking with users."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import User, UserRelationship
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipLevel(Enum):
|
||||||
|
"""Relationship levels based on score."""
|
||||||
|
|
||||||
|
STRANGER = "stranger" # 0-20
|
||||||
|
ACQUAINTANCE = "acquaintance" # 21-40
|
||||||
|
FRIEND = "friend" # 41-60
|
||||||
|
GOOD_FRIEND = "good_friend" # 61-80
|
||||||
|
CLOSE_FRIEND = "close_friend" # 81-100
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipService:
|
||||||
|
"""Manages relationship tracking and dynamics."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_or_create_relationship(
|
||||||
|
self, user: User, guild_id: int | None = None
|
||||||
|
) -> UserRelationship:
|
||||||
|
"""Get or create relationship record for a user."""
|
||||||
|
stmt = select(UserRelationship).where(
|
||||||
|
UserRelationship.user_id == user.id,
|
||||||
|
UserRelationship.guild_id == guild_id,
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
rel = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not rel:
|
||||||
|
rel = UserRelationship(user_id=user.id, guild_id=guild_id)
|
||||||
|
self._session.add(rel)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
return rel
|
||||||
|
|
||||||
|
async def record_interaction(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
guild_id: int | None,
|
||||||
|
sentiment: float,
|
||||||
|
message_length: int,
|
||||||
|
conversation_turns: int = 1,
|
||||||
|
) -> RelationshipLevel:
|
||||||
|
"""Record an interaction and update relationship score.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user who interacted
|
||||||
|
guild_id: Guild ID or None for global
|
||||||
|
sentiment: -1 to 1, how positive the interaction was
|
||||||
|
message_length: Length of user's message
|
||||||
|
conversation_turns: Number of back-and-forth exchanges
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The current relationship level
|
||||||
|
"""
|
||||||
|
rel = await self.get_or_create_relationship(user, guild_id)
|
||||||
|
|
||||||
|
rel.total_interactions += 1
|
||||||
|
rel.last_interaction_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Track sentiment
|
||||||
|
if sentiment > 0.2:
|
||||||
|
rel.positive_interactions += 1
|
||||||
|
elif sentiment < -0.2:
|
||||||
|
rel.negative_interactions += 1
|
||||||
|
|
||||||
|
# Update running averages
|
||||||
|
n = rel.total_interactions
|
||||||
|
rel.avg_message_length = ((rel.avg_message_length * (n - 1)) + message_length) / n
|
||||||
|
rel.conversation_depth_avg = (
|
||||||
|
(rel.conversation_depth_avg * (n - 1)) + conversation_turns
|
||||||
|
) / n
|
||||||
|
|
||||||
|
# Calculate score change
|
||||||
|
score_delta = self._calculate_score_delta(sentiment, message_length, conversation_turns)
|
||||||
|
rel.relationship_score = min(100, max(0, rel.relationship_score + score_delta))
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Relationship updated for user {user.id}: "
|
||||||
|
f"score={rel.relationship_score:.1f}, delta={score_delta:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get_level(rel.relationship_score)
|
||||||
|
|
||||||
|
def get_level(self, score: float) -> RelationshipLevel:
|
||||||
|
"""Get relationship level from score."""
|
||||||
|
if score <= 20:
|
||||||
|
return RelationshipLevel.STRANGER
|
||||||
|
elif score <= 40:
|
||||||
|
return RelationshipLevel.ACQUAINTANCE
|
||||||
|
elif score <= 60:
|
||||||
|
return RelationshipLevel.FRIEND
|
||||||
|
elif score <= 80:
|
||||||
|
return RelationshipLevel.GOOD_FRIEND
|
||||||
|
else:
|
||||||
|
return RelationshipLevel.CLOSE_FRIEND
|
||||||
|
|
||||||
|
def get_level_display_name(self, level: RelationshipLevel) -> str:
|
||||||
|
"""Get a human-readable name for the relationship level."""
|
||||||
|
names = {
|
||||||
|
RelationshipLevel.STRANGER: "Stranger",
|
||||||
|
RelationshipLevel.ACQUAINTANCE: "Acquaintance",
|
||||||
|
RelationshipLevel.FRIEND: "Friend",
|
||||||
|
RelationshipLevel.GOOD_FRIEND: "Good Friend",
|
||||||
|
RelationshipLevel.CLOSE_FRIEND: "Close Friend",
|
||||||
|
}
|
||||||
|
return names.get(level, "Unknown")
|
||||||
|
|
||||||
|
async def add_shared_reference(
|
||||||
|
self, user: User, guild_id: int | None, reference_type: str, content: str
|
||||||
|
) -> None:
|
||||||
|
"""Add a shared reference (inside joke, nickname, etc.)."""
|
||||||
|
rel = await self.get_or_create_relationship(user, guild_id)
|
||||||
|
|
||||||
|
refs = rel.shared_references or {}
|
||||||
|
if reference_type not in refs:
|
||||||
|
refs[reference_type] = []
|
||||||
|
|
||||||
|
# Avoid duplicates and limit to 10 per type
|
||||||
|
if content not in refs[reference_type]:
|
||||||
|
refs[reference_type].append(content)
|
||||||
|
refs[reference_type] = refs[reference_type][-10:] # Keep last 10
|
||||||
|
|
||||||
|
rel.shared_references = refs
|
||||||
|
|
||||||
|
def get_relationship_prompt_modifier(
|
||||||
|
self, level: RelationshipLevel, relationship: UserRelationship
|
||||||
|
) -> str:
|
||||||
|
"""Generate prompt text reflecting relationship level."""
|
||||||
|
base_modifiers = {
|
||||||
|
RelationshipLevel.STRANGER: (
|
||||||
|
"This is someone you don't know well yet. "
|
||||||
|
"Be polite and welcoming, but keep some professional distance. "
|
||||||
|
"Use more formal language."
|
||||||
|
),
|
||||||
|
RelationshipLevel.ACQUAINTANCE: (
|
||||||
|
"This is someone you've chatted with a few times. "
|
||||||
|
"Be friendly and warm, but still somewhat reserved."
|
||||||
|
),
|
||||||
|
RelationshipLevel.FRIEND: (
|
||||||
|
"This is a friend! Be casual and warm. "
|
||||||
|
"Use their name occasionally, show you remember past conversations."
|
||||||
|
),
|
||||||
|
RelationshipLevel.GOOD_FRIEND: (
|
||||||
|
"This is a good friend you know well. "
|
||||||
|
"Be relaxed and personal. Reference things you've talked about before. "
|
||||||
|
"Feel free to be playful."
|
||||||
|
),
|
||||||
|
RelationshipLevel.CLOSE_FRIEND: (
|
||||||
|
"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."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
modifier = base_modifiers.get(level, "")
|
||||||
|
|
||||||
|
# Add shared references for closer relationships
|
||||||
|
if level in (RelationshipLevel.GOOD_FRIEND, RelationshipLevel.CLOSE_FRIEND):
|
||||||
|
refs = relationship.shared_references or {}
|
||||||
|
if refs.get("jokes"):
|
||||||
|
jokes = refs["jokes"][:2]
|
||||||
|
modifier += f" You have inside jokes together: {', '.join(jokes)}."
|
||||||
|
if refs.get("nicknames"):
|
||||||
|
nicknames = refs["nicknames"][:1]
|
||||||
|
modifier += f" You sometimes call them: {nicknames[0]}."
|
||||||
|
|
||||||
|
return modifier
|
||||||
|
|
||||||
|
def _calculate_score_delta(
|
||||||
|
self, sentiment: float, message_length: int, conversation_turns: int
|
||||||
|
) -> float:
|
||||||
|
"""Calculate how much the relationship score should change.
|
||||||
|
|
||||||
|
Positive interactions increase score, negative decrease.
|
||||||
|
Longer messages and deeper conversations increase score more.
|
||||||
|
"""
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
async def get_relationship_info(self, user: User, guild_id: int | None = None) -> dict:
|
||||||
|
"""Get detailed relationship information for display."""
|
||||||
|
rel = await self.get_or_create_relationship(user, guild_id)
|
||||||
|
level = self.get_level(rel.relationship_score)
|
||||||
|
|
||||||
|
# Calculate time since first interaction
|
||||||
|
time_known = datetime.now(timezone.utc) - rel.first_interaction_at
|
||||||
|
days_known = time_known.days
|
||||||
|
|
||||||
|
return {
|
||||||
|
"level": level,
|
||||||
|
"level_name": self.get_level_display_name(level),
|
||||||
|
"score": rel.relationship_score,
|
||||||
|
"total_interactions": rel.total_interactions,
|
||||||
|
"positive_interactions": rel.positive_interactions,
|
||||||
|
"negative_interactions": rel.negative_interactions,
|
||||||
|
"first_interaction_at": rel.first_interaction_at,
|
||||||
|
"last_interaction_at": rel.last_interaction_at,
|
||||||
|
"days_known": days_known,
|
||||||
|
"shared_references": rel.shared_references or {},
|
||||||
|
}
|
||||||
220
src/daemon_boyfriend/services/self_awareness_service.py
Normal file
220
src/daemon_boyfriend/services/self_awareness_service.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""Self Awareness Service - provides bot self-reflection and statistics."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import (
|
||||||
|
BotOpinion,
|
||||||
|
BotState,
|
||||||
|
User,
|
||||||
|
UserFact,
|
||||||
|
UserRelationship,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SelfAwarenessService:
|
||||||
|
"""Provides bot self-reflection and statistics."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_bot_stats(self, guild_id: int | None = None) -> dict:
|
||||||
|
"""Get comprehensive bot statistics."""
|
||||||
|
# Get or create bot state
|
||||||
|
stmt = select(BotState).where(BotState.guild_id == guild_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
bot_state = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not bot_state:
|
||||||
|
bot_state = BotState(guild_id=guild_id)
|
||||||
|
self._session.add(bot_state)
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
|
# Calculate age
|
||||||
|
age_delta = datetime.now(timezone.utc) - bot_state.first_activated_at
|
||||||
|
|
||||||
|
# Count users (from database)
|
||||||
|
user_count = await self._count_users()
|
||||||
|
|
||||||
|
# Count facts
|
||||||
|
fact_count = await self._count_facts()
|
||||||
|
|
||||||
|
# Get top interests
|
||||||
|
top_interests = await self._get_top_interests(guild_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"age_days": age_delta.days,
|
||||||
|
"age_readable": self._format_age(age_delta),
|
||||||
|
"first_activated_at": bot_state.first_activated_at,
|
||||||
|
"total_messages_sent": bot_state.total_messages_sent,
|
||||||
|
"total_facts_learned": fact_count,
|
||||||
|
"total_users_known": user_count,
|
||||||
|
"favorite_topics": [op.topic for op in top_interests],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_history_with_user(self, user: User, guild_id: int | None = None) -> dict:
|
||||||
|
"""Get the bot's history with a specific user."""
|
||||||
|
# Get relationship
|
||||||
|
stmt = select(UserRelationship).where(
|
||||||
|
UserRelationship.user_id == user.id,
|
||||||
|
UserRelationship.guild_id == guild_id,
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
rel = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Get facts count
|
||||||
|
facts_stmt = select(func.count(UserFact.id)).where(
|
||||||
|
UserFact.user_id == user.id,
|
||||||
|
UserFact.is_active == True,
|
||||||
|
)
|
||||||
|
facts_result = await self._session.execute(facts_stmt)
|
||||||
|
facts_count = facts_result.scalar() or 0
|
||||||
|
|
||||||
|
if rel:
|
||||||
|
days_known = (datetime.now(timezone.utc) - rel.first_interaction_at).days
|
||||||
|
return {
|
||||||
|
"first_met": rel.first_interaction_at,
|
||||||
|
"days_known": days_known,
|
||||||
|
"total_interactions": rel.total_interactions,
|
||||||
|
"relationship_score": rel.relationship_score,
|
||||||
|
"things_known": facts_count,
|
||||||
|
"shared_references": rel.shared_references or {},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"first_met": None,
|
||||||
|
"days_known": 0,
|
||||||
|
"total_interactions": 0,
|
||||||
|
"relationship_score": 0,
|
||||||
|
"things_known": facts_count,
|
||||||
|
"shared_references": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def reflect_on_self(self, guild_id: int | None = None) -> str:
|
||||||
|
"""Generate a self-reflection statement."""
|
||||||
|
stats = await self.get_bot_stats(guild_id)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Age
|
||||||
|
if stats["age_days"] > 0:
|
||||||
|
parts.append(f"I've been around for {stats['age_readable']}.")
|
||||||
|
else:
|
||||||
|
parts.append("I'm pretty new here!")
|
||||||
|
|
||||||
|
# People known
|
||||||
|
if stats["total_users_known"] > 0:
|
||||||
|
parts.append(f"I've gotten to know {stats['total_users_known']} people.")
|
||||||
|
|
||||||
|
# Facts learned
|
||||||
|
if stats["total_facts_learned"] > 0:
|
||||||
|
parts.append(f"I've learned {stats['total_facts_learned']} things about them.")
|
||||||
|
|
||||||
|
# Favorite topics
|
||||||
|
if stats["favorite_topics"]:
|
||||||
|
topics = ", ".join(stats["favorite_topics"][:3])
|
||||||
|
parts.append(f"I find myself most interested in {topics}.")
|
||||||
|
|
||||||
|
# Messages
|
||||||
|
if stats["total_messages_sent"] > 0:
|
||||||
|
parts.append(f"I've sent {stats['total_messages_sent']} messages so far.")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
async def reflect_on_user(self, user: User, guild_id: int | None = None) -> str:
|
||||||
|
"""Generate a reflection about the bot's history with a user."""
|
||||||
|
history = await self.get_history_with_user(user, guild_id)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
if history["first_met"]:
|
||||||
|
if history["days_known"] == 0:
|
||||||
|
parts.append("We just met today!")
|
||||||
|
elif history["days_known"] == 1:
|
||||||
|
parts.append("We met yesterday.")
|
||||||
|
elif history["days_known"] < 7:
|
||||||
|
parts.append(f"We met {history['days_known']} days ago.")
|
||||||
|
elif history["days_known"] < 30:
|
||||||
|
weeks = history["days_known"] // 7
|
||||||
|
parts.append(
|
||||||
|
f"We've known each other for about {weeks} week{'s' if weeks > 1 else ''}."
|
||||||
|
)
|
||||||
|
elif history["days_known"] < 365:
|
||||||
|
months = history["days_known"] // 30
|
||||||
|
parts.append(
|
||||||
|
f"We've known each other for about {months} month{'s' if months > 1 else ''}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
years = history["days_known"] // 365
|
||||||
|
parts.append(
|
||||||
|
f"We've known each other for over {years} year{'s' if years > 1 else ''}!"
|
||||||
|
)
|
||||||
|
|
||||||
|
if history["total_interactions"] > 0:
|
||||||
|
parts.append(f"We've chatted about {history['total_interactions']} times.")
|
||||||
|
|
||||||
|
if history["things_known"] > 0:
|
||||||
|
parts.append(f"I've learned {history['things_known']} things about you.")
|
||||||
|
else:
|
||||||
|
parts.append("I don't think we've properly met before!")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
async def _count_users(self) -> int:
|
||||||
|
"""Count total users in the database."""
|
||||||
|
stmt = select(func.count(User.id)).where(User.is_active == True)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
async def _count_facts(self) -> int:
|
||||||
|
"""Count total facts in the database."""
|
||||||
|
stmt = select(func.count(UserFact.id)).where(UserFact.is_active == True)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
async def _get_top_interests(self, guild_id: int | None, limit: int = 3) -> list[BotOpinion]:
|
||||||
|
"""Get top interests."""
|
||||||
|
stmt = (
|
||||||
|
select(BotOpinion)
|
||||||
|
.where(
|
||||||
|
BotOpinion.guild_id == guild_id,
|
||||||
|
BotOpinion.interest_level > 0.5,
|
||||||
|
)
|
||||||
|
.order_by(BotOpinion.interest_level.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
def _format_age(self, delta) -> str:
|
||||||
|
"""Format a timedelta into a readable string."""
|
||||||
|
days = delta.days
|
||||||
|
if days == 0:
|
||||||
|
hours = delta.seconds // 3600
|
||||||
|
if hours == 0:
|
||||||
|
minutes = delta.seconds // 60
|
||||||
|
if minutes == 0:
|
||||||
|
return "just a moment"
|
||||||
|
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||||
|
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||||
|
elif days == 1:
|
||||||
|
return "1 day"
|
||||||
|
elif days < 7:
|
||||||
|
return f"{days} days"
|
||||||
|
elif days < 30:
|
||||||
|
weeks = days // 7
|
||||||
|
return f"about {weeks} week{'s' if weeks != 1 else ''}"
|
||||||
|
elif days < 365:
|
||||||
|
months = days // 30
|
||||||
|
return f"about {months} month{'s' if months != 1 else ''}"
|
||||||
|
else:
|
||||||
|
years = days // 365
|
||||||
|
months = (days % 365) // 30
|
||||||
|
if months > 0:
|
||||||
|
return f"about {years} year{'s' if years != 1 else ''} and {months} month{'s' if months != 1 else ''}"
|
||||||
|
return f"about {years} year{'s' if years != 1 else ''}"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""User management service."""
|
"""User management service."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -40,7 +40,7 @@ class UserService:
|
|||||||
|
|
||||||
if user:
|
if user:
|
||||||
# Update last seen and current name
|
# Update last seen and current name
|
||||||
user.last_seen_at = datetime.utcnow()
|
user.last_seen_at = datetime.now(timezone.utc)
|
||||||
user.discord_username = username
|
user.discord_username = username
|
||||||
if display_name:
|
if display_name:
|
||||||
user.discord_display_name = display_name
|
user.discord_display_name = display_name
|
||||||
@@ -232,7 +232,7 @@ class UserService:
|
|||||||
for fact in facts[:20]: # Limit to 20 most recent facts
|
for fact in facts[:20]: # Limit to 20 most recent facts
|
||||||
lines.append(f"- [{fact.fact_type}] {fact.fact_content}")
|
lines.append(f"- [{fact.fact_type}] {fact.fact_content}")
|
||||||
# Mark as referenced
|
# Mark as referenced
|
||||||
fact.last_referenced_at = datetime.utcnow()
|
fact.last_referenced_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Test suite for Daemon Boyfriend Discord bot."""
|
||||||
309
tests/conftest.py
Normal file
309
tests/conftest.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""Pytest configuration and fixtures for the test suite."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import AsyncGenerator, Generator
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from daemon_boyfriend.config import Settings
|
||||||
|
from daemon_boyfriend.models.base import Base
|
||||||
|
|
||||||
|
# --- Event Loop Fixture ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
|
||||||
|
"""Create an event loop for the test session."""
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Database Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def async_engine():
|
||||||
|
"""Create an async SQLite engine for testing."""
|
||||||
|
engine = create_async_engine(
|
||||||
|
"sqlite+aiosqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Create a database session for testing."""
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
async_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
yield session
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mock Settings Fixture ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings() -> Settings:
|
||||||
|
"""Create mock settings for testing."""
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{
|
||||||
|
"DISCORD_TOKEN": "test_token",
|
||||||
|
"AI_PROVIDER": "openai",
|
||||||
|
"AI_MODEL": "gpt-4o-mini",
|
||||||
|
"OPENAI_API_KEY": "test_openai_key",
|
||||||
|
"ANTHROPIC_API_KEY": "test_anthropic_key",
|
||||||
|
"GEMINI_API_KEY": "test_gemini_key",
|
||||||
|
"OPENROUTER_API_KEY": "test_openrouter_key",
|
||||||
|
"BOT_NAME": "TestBot",
|
||||||
|
"BOT_PERSONALITY": "helpful and friendly",
|
||||||
|
"DATABASE_URL": "",
|
||||||
|
"LIVING_AI_ENABLED": "true",
|
||||||
|
"MOOD_ENABLED": "true",
|
||||||
|
"RELATIONSHIP_ENABLED": "true",
|
||||||
|
},
|
||||||
|
):
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mock Discord Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_discord_user() -> MagicMock:
|
||||||
|
"""Create a mock Discord user."""
|
||||||
|
user = MagicMock()
|
||||||
|
user.id = 123456789
|
||||||
|
user.name = "TestUser"
|
||||||
|
user.display_name = "Test User"
|
||||||
|
user.mention = "<@123456789>"
|
||||||
|
user.bot = False
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_discord_message(mock_discord_user) -> MagicMock:
|
||||||
|
"""Create a mock Discord message."""
|
||||||
|
message = MagicMock()
|
||||||
|
message.author = mock_discord_user
|
||||||
|
message.content = "Hello, bot!"
|
||||||
|
message.channel = MagicMock()
|
||||||
|
message.channel.id = 987654321
|
||||||
|
message.channel.send = AsyncMock()
|
||||||
|
message.channel.typing = MagicMock(return_value=AsyncMock())
|
||||||
|
message.guild = MagicMock()
|
||||||
|
message.guild.id = 111222333
|
||||||
|
message.guild.name = "Test Guild"
|
||||||
|
message.id = 555666777
|
||||||
|
message.mentions = []
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_discord_bot() -> MagicMock:
|
||||||
|
"""Create a mock Discord bot."""
|
||||||
|
bot = MagicMock()
|
||||||
|
bot.user = MagicMock()
|
||||||
|
bot.user.id = 999888777
|
||||||
|
bot.user.name = "TestBot"
|
||||||
|
bot.user.mentioned_in = MagicMock(return_value=True)
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
# --- Mock AI Provider Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ai_response() -> MagicMock:
|
||||||
|
"""Create a mock AI response."""
|
||||||
|
from daemon_boyfriend.services.providers.base import AIResponse
|
||||||
|
|
||||||
|
return AIResponse(
|
||||||
|
content="This is a test response from the AI.",
|
||||||
|
model="test-model",
|
||||||
|
usage={"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_openai_client() -> MagicMock:
|
||||||
|
"""Create a mock OpenAI client."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
response = MagicMock()
|
||||||
|
response.choices = [MagicMock()]
|
||||||
|
response.choices[0].message.content = "Test OpenAI response"
|
||||||
|
response.model = "gpt-4o-mini"
|
||||||
|
response.usage = MagicMock()
|
||||||
|
response.usage.prompt_tokens = 10
|
||||||
|
response.usage.completion_tokens = 20
|
||||||
|
response.usage.total_tokens = 30
|
||||||
|
|
||||||
|
client.chat.completions.create = AsyncMock(return_value=response)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anthropic_client() -> MagicMock:
|
||||||
|
"""Create a mock Anthropic client."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
response = MagicMock()
|
||||||
|
response.content = [MagicMock()]
|
||||||
|
response.content[0].type = "text"
|
||||||
|
response.content[0].text = "Test Anthropic response"
|
||||||
|
response.model = "claude-sonnet-4-20250514"
|
||||||
|
response.usage = MagicMock()
|
||||||
|
response.usage.input_tokens = 10
|
||||||
|
response.usage.output_tokens = 20
|
||||||
|
|
||||||
|
client.messages.create = AsyncMock(return_value=response)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_gemini_client() -> MagicMock:
|
||||||
|
"""Create a mock Gemini client."""
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
response = MagicMock()
|
||||||
|
response.text = "Test Gemini response"
|
||||||
|
response.usage_metadata = MagicMock()
|
||||||
|
response.usage_metadata.prompt_token_count = 10
|
||||||
|
response.usage_metadata.candidates_token_count = 20
|
||||||
|
response.usage_metadata.total_token_count = 30
|
||||||
|
|
||||||
|
client.aio.models.generate_content = AsyncMock(return_value=response)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# --- Model Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_user(db_session: AsyncSession):
|
||||||
|
"""Create a sample user in the database."""
|
||||||
|
from daemon_boyfriend.models import User
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
discord_id=123456789,
|
||||||
|
discord_username="testuser",
|
||||||
|
discord_display_name="Test User",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_user_with_facts(db_session: AsyncSession, sample_user):
|
||||||
|
"""Create a sample user with facts."""
|
||||||
|
from daemon_boyfriend.models import UserFact
|
||||||
|
|
||||||
|
facts = [
|
||||||
|
UserFact(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
fact_type="hobby",
|
||||||
|
fact_content="likes programming",
|
||||||
|
confidence=1.0,
|
||||||
|
source="explicit",
|
||||||
|
),
|
||||||
|
UserFact(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
fact_type="preference",
|
||||||
|
fact_content="prefers dark mode",
|
||||||
|
confidence=0.8,
|
||||||
|
source="conversation",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for fact in facts:
|
||||||
|
db_session.add(fact)
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
return sample_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_conversation(db_session: AsyncSession, sample_user):
|
||||||
|
"""Create a sample conversation."""
|
||||||
|
from daemon_boyfriend.models import Conversation
|
||||||
|
|
||||||
|
conversation = Conversation(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
guild_id=111222333,
|
||||||
|
channel_id=987654321,
|
||||||
|
)
|
||||||
|
db_session.add(conversation)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(conversation)
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_bot_state(db_session: AsyncSession):
|
||||||
|
"""Create a sample bot state."""
|
||||||
|
from daemon_boyfriend.models import BotState
|
||||||
|
|
||||||
|
bot_state = BotState(
|
||||||
|
guild_id=111222333,
|
||||||
|
mood_valence=0.5,
|
||||||
|
mood_arousal=0.3,
|
||||||
|
)
|
||||||
|
db_session.add(bot_state)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(bot_state)
|
||||||
|
return bot_state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_user_relationship(db_session: AsyncSession, sample_user):
|
||||||
|
"""Create a sample user relationship."""
|
||||||
|
from daemon_boyfriend.models import UserRelationship
|
||||||
|
|
||||||
|
relationship = UserRelationship(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
guild_id=111222333,
|
||||||
|
relationship_score=50.0,
|
||||||
|
total_interactions=10,
|
||||||
|
positive_interactions=8,
|
||||||
|
negative_interactions=1,
|
||||||
|
)
|
||||||
|
db_session.add(relationship)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(relationship)
|
||||||
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
# --- Utility Fixtures ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
"""Get current UTC time."""
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
487
tests/test_models.py
Normal file
487
tests/test_models.py
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
"""Tests for database models."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import (
|
||||||
|
BotOpinion,
|
||||||
|
BotState,
|
||||||
|
Conversation,
|
||||||
|
FactAssociation,
|
||||||
|
Guild,
|
||||||
|
GuildMember,
|
||||||
|
Message,
|
||||||
|
MoodHistory,
|
||||||
|
ScheduledEvent,
|
||||||
|
User,
|
||||||
|
UserCommunicationStyle,
|
||||||
|
UserFact,
|
||||||
|
UserPreference,
|
||||||
|
UserRelationship,
|
||||||
|
)
|
||||||
|
from daemon_boyfriend.models.base import utc_now
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtcNow:
|
||||||
|
"""Tests for the utc_now helper function."""
|
||||||
|
|
||||||
|
def test_returns_timezone_aware(self):
|
||||||
|
"""Test that utc_now returns timezone-aware datetime."""
|
||||||
|
now = utc_now()
|
||||||
|
assert now.tzinfo is not None
|
||||||
|
assert now.tzinfo == timezone.utc
|
||||||
|
|
||||||
|
def test_returns_current_time(self):
|
||||||
|
"""Test that utc_now returns approximately current time."""
|
||||||
|
before = datetime.now(timezone.utc)
|
||||||
|
now = utc_now()
|
||||||
|
after = datetime.now(timezone.utc)
|
||||||
|
assert before <= now <= after
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserModel:
|
||||||
|
"""Tests for the User model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user(self, db_session):
|
||||||
|
"""Test creating a user."""
|
||||||
|
user = User(
|
||||||
|
discord_id=123456789,
|
||||||
|
discord_username="testuser",
|
||||||
|
discord_display_name="Test User",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert user.id is not None
|
||||||
|
assert user.discord_id == 123456789
|
||||||
|
assert user.is_active is True
|
||||||
|
assert user.created_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_display_name_property(self, db_session):
|
||||||
|
"""Test the display_name property."""
|
||||||
|
user = User(
|
||||||
|
discord_id=123456789,
|
||||||
|
discord_username="testuser",
|
||||||
|
discord_display_name="Test User",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Uses discord_display_name when no custom_name
|
||||||
|
assert user.display_name == "Test User"
|
||||||
|
|
||||||
|
# Uses custom_name when set
|
||||||
|
user.custom_name = "Custom Name"
|
||||||
|
assert user.display_name == "Custom Name"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_display_name_fallback(self, db_session):
|
||||||
|
"""Test display_name falls back to username."""
|
||||||
|
user = User(
|
||||||
|
discord_id=123456789,
|
||||||
|
discord_username="testuser",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert user.display_name == "testuser"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_timestamps(self, db_session):
|
||||||
|
"""Test user timestamp fields."""
|
||||||
|
user = User(
|
||||||
|
discord_id=123456789,
|
||||||
|
discord_username="testuser",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert user.first_seen_at is not None
|
||||||
|
assert user.last_seen_at is not None
|
||||||
|
assert user.created_at is not None
|
||||||
|
assert user.updated_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserFactModel:
|
||||||
|
"""Tests for the UserFact model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_fact(self, db_session, sample_user):
|
||||||
|
"""Test creating a user fact."""
|
||||||
|
fact = UserFact(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
fact_type="hobby",
|
||||||
|
fact_content="likes gaming",
|
||||||
|
confidence=0.9,
|
||||||
|
source="conversation",
|
||||||
|
)
|
||||||
|
db_session.add(fact)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert fact.id is not None
|
||||||
|
assert fact.is_active is True
|
||||||
|
assert fact.learned_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fact_default_values(self, db_session, sample_user):
|
||||||
|
"""Test fact default values."""
|
||||||
|
fact = UserFact(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
fact_type="general",
|
||||||
|
fact_content="test fact",
|
||||||
|
)
|
||||||
|
db_session.add(fact)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert fact.confidence == 1.0
|
||||||
|
assert fact.source == "conversation"
|
||||||
|
assert fact.is_active is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestConversationModel:
|
||||||
|
"""Tests for the Conversation model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_conversation(self, db_session, sample_user):
|
||||||
|
"""Test creating a conversation."""
|
||||||
|
conv = Conversation(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
guild_id=111222333,
|
||||||
|
channel_id=444555666,
|
||||||
|
)
|
||||||
|
db_session.add(conv)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert conv.id is not None
|
||||||
|
assert conv.message_count == 0
|
||||||
|
assert conv.is_active is True
|
||||||
|
assert conv.started_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_conversation_with_messages(self, db_session, sample_user):
|
||||||
|
"""Test conversation with messages."""
|
||||||
|
conv = Conversation(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
channel_id=444555666,
|
||||||
|
)
|
||||||
|
db_session.add(conv)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=conv.id,
|
||||||
|
user_id=sample_user.id,
|
||||||
|
role="user",
|
||||||
|
content="Hello!",
|
||||||
|
)
|
||||||
|
db_session.add(msg)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert msg.id is not None
|
||||||
|
assert msg.role == "user"
|
||||||
|
assert msg.has_images is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageModel:
|
||||||
|
"""Tests for the Message model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_message(self, db_session, sample_conversation, sample_user):
|
||||||
|
"""Test creating a message."""
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=sample_conversation.id,
|
||||||
|
user_id=sample_user.id,
|
||||||
|
role="user",
|
||||||
|
content="Test message",
|
||||||
|
)
|
||||||
|
db_session.add(msg)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert msg.id is not None
|
||||||
|
assert msg.has_images is False
|
||||||
|
assert msg.image_urls is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_message_with_images(self, db_session, sample_conversation, sample_user):
|
||||||
|
"""Test message with images."""
|
||||||
|
msg = Message(
|
||||||
|
conversation_id=sample_conversation.id,
|
||||||
|
user_id=sample_user.id,
|
||||||
|
role="user",
|
||||||
|
content="Look at this",
|
||||||
|
has_images=True,
|
||||||
|
image_urls=["https://example.com/image.png"],
|
||||||
|
)
|
||||||
|
db_session.add(msg)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert msg.has_images is True
|
||||||
|
assert len(msg.image_urls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuildModel:
|
||||||
|
"""Tests for the Guild model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_guild(self, db_session):
|
||||||
|
"""Test creating a guild."""
|
||||||
|
guild = Guild(
|
||||||
|
discord_id=111222333,
|
||||||
|
name="Test Guild",
|
||||||
|
)
|
||||||
|
db_session.add(guild)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert guild.id is not None
|
||||||
|
assert guild.is_active is True
|
||||||
|
assert guild.settings == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_guild_with_settings(self, db_session):
|
||||||
|
"""Test guild with custom settings."""
|
||||||
|
guild = Guild(
|
||||||
|
discord_id=111222333,
|
||||||
|
name="Test Guild",
|
||||||
|
settings={"prefix": "!", "language": "en"},
|
||||||
|
)
|
||||||
|
db_session.add(guild)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert guild.settings["prefix"] == "!"
|
||||||
|
assert guild.settings["language"] == "en"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuildMemberModel:
|
||||||
|
"""Tests for the GuildMember model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_guild_member(self, db_session, sample_user):
|
||||||
|
"""Test creating a guild member."""
|
||||||
|
guild = Guild(discord_id=111222333, name="Test Guild")
|
||||||
|
db_session.add(guild)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
member = GuildMember(
|
||||||
|
guild_id=guild.id,
|
||||||
|
user_id=sample_user.id,
|
||||||
|
guild_nickname="TestNick",
|
||||||
|
)
|
||||||
|
db_session.add(member)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert member.id is not None
|
||||||
|
assert member.guild_nickname == "TestNick"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotStateModel:
|
||||||
|
"""Tests for the BotState model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_bot_state(self, db_session):
|
||||||
|
"""Test creating a bot state."""
|
||||||
|
state = BotState(guild_id=111222333)
|
||||||
|
db_session.add(state)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert state.id is not None
|
||||||
|
assert state.mood_valence == 0.0
|
||||||
|
assert state.mood_arousal == 0.0
|
||||||
|
assert state.total_messages_sent == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_state_defaults(self, db_session):
|
||||||
|
"""Test bot state default values."""
|
||||||
|
state = BotState()
|
||||||
|
db_session.add(state)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert state.guild_id is None
|
||||||
|
assert state.preferences == {}
|
||||||
|
assert state.total_facts_learned == 0
|
||||||
|
assert state.total_users_known == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotOpinionModel:
|
||||||
|
"""Tests for the BotOpinion model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_opinion(self, db_session):
|
||||||
|
"""Test creating a bot opinion."""
|
||||||
|
opinion = BotOpinion(
|
||||||
|
topic="programming",
|
||||||
|
sentiment=0.8,
|
||||||
|
interest_level=0.9,
|
||||||
|
)
|
||||||
|
db_session.add(opinion)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert opinion.id is not None
|
||||||
|
assert opinion.discussion_count == 0
|
||||||
|
assert opinion.formed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserRelationshipModel:
|
||||||
|
"""Tests for the UserRelationship model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_relationship(self, db_session, sample_user):
|
||||||
|
"""Test creating a user relationship."""
|
||||||
|
rel = UserRelationship(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
guild_id=111222333,
|
||||||
|
)
|
||||||
|
db_session.add(rel)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert rel.id is not None
|
||||||
|
assert rel.relationship_score == 10.0
|
||||||
|
assert rel.total_interactions == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_relationship_defaults(self, db_session, sample_user):
|
||||||
|
"""Test relationship default values."""
|
||||||
|
rel = UserRelationship(user_id=sample_user.id)
|
||||||
|
db_session.add(rel)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert rel.shared_references == {}
|
||||||
|
assert rel.positive_interactions == 0
|
||||||
|
assert rel.negative_interactions == 0
|
||||||
|
assert rel.avg_message_length == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserCommunicationStyleModel:
|
||||||
|
"""Tests for the UserCommunicationStyle model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_style(self, db_session, sample_user):
|
||||||
|
"""Test creating a communication style."""
|
||||||
|
style = UserCommunicationStyle(user_id=sample_user.id)
|
||||||
|
db_session.add(style)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert style.id is not None
|
||||||
|
assert style.preferred_length == "medium"
|
||||||
|
assert style.preferred_formality == 0.5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_style_defaults(self, db_session, sample_user):
|
||||||
|
"""Test communication style defaults."""
|
||||||
|
style = UserCommunicationStyle(user_id=sample_user.id)
|
||||||
|
db_session.add(style)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert style.emoji_affinity == 0.5
|
||||||
|
assert style.humor_affinity == 0.5
|
||||||
|
assert style.detail_preference == 0.5
|
||||||
|
assert style.samples_collected == 0
|
||||||
|
assert style.confidence == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduledEventModel:
|
||||||
|
"""Tests for the ScheduledEvent model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_event(self, db_session, sample_user):
|
||||||
|
"""Test creating a scheduled event."""
|
||||||
|
trigger_time = datetime.now(timezone.utc) + timedelta(days=1)
|
||||||
|
event = ScheduledEvent(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
event_type="birthday",
|
||||||
|
trigger_at=trigger_time,
|
||||||
|
title="Birthday reminder",
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert event.id is not None
|
||||||
|
assert event.status == "pending"
|
||||||
|
assert event.is_recurring is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recurring_event(self, db_session, sample_user):
|
||||||
|
"""Test creating a recurring event."""
|
||||||
|
trigger_time = datetime.now(timezone.utc) + timedelta(days=1)
|
||||||
|
event = ScheduledEvent(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
event_type="birthday",
|
||||||
|
trigger_at=trigger_time,
|
||||||
|
title="Birthday",
|
||||||
|
is_recurring=True,
|
||||||
|
recurrence_rule="yearly",
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert event.is_recurring is True
|
||||||
|
assert event.recurrence_rule == "yearly"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFactAssociationModel:
|
||||||
|
"""Tests for the FactAssociation model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_association(self, db_session, sample_user):
|
||||||
|
"""Test creating a fact association."""
|
||||||
|
fact1 = UserFact(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
fact_type="hobby",
|
||||||
|
fact_content="likes programming",
|
||||||
|
)
|
||||||
|
fact2 = UserFact(
|
||||||
|
user_id=sample_user.id,
|
||||||
|
fact_type="hobby",
|
||||||
|
fact_content="likes Python",
|
||||||
|
)
|
||||||
|
db_session.add(fact1)
|
||||||
|
db_session.add(fact2)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assoc = FactAssociation(
|
||||||
|
fact_id_1=fact1.id,
|
||||||
|
fact_id_2=fact2.id,
|
||||||
|
association_type="shared_interest",
|
||||||
|
strength=0.8,
|
||||||
|
)
|
||||||
|
db_session.add(assoc)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert assoc.id is not None
|
||||||
|
assert assoc.discovered_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoodHistoryModel:
|
||||||
|
"""Tests for the MoodHistory model."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_mood_history(self, db_session, sample_user):
|
||||||
|
"""Test creating a mood history entry."""
|
||||||
|
history = MoodHistory(
|
||||||
|
guild_id=111222333,
|
||||||
|
valence=0.5,
|
||||||
|
arousal=0.3,
|
||||||
|
trigger_type="conversation",
|
||||||
|
trigger_user_id=sample_user.id,
|
||||||
|
trigger_description="Had a nice chat",
|
||||||
|
)
|
||||||
|
db_session.add(history)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert history.id is not None
|
||||||
|
assert history.recorded_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mood_history_without_user(self, db_session):
|
||||||
|
"""Test mood history without trigger user."""
|
||||||
|
history = MoodHistory(
|
||||||
|
valence=-0.2,
|
||||||
|
arousal=-0.1,
|
||||||
|
trigger_type="time_decay",
|
||||||
|
)
|
||||||
|
db_session.add(history)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
assert history.trigger_user_id is None
|
||||||
|
assert history.trigger_description is None
|
||||||
290
tests/test_providers.py
Normal file
290
tests/test_providers.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""Tests for AI provider implementations."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from daemon_boyfriend.services.providers.base import (
|
||||||
|
AIProvider,
|
||||||
|
AIResponse,
|
||||||
|
Message,
|
||||||
|
ImageAttachment,
|
||||||
|
)
|
||||||
|
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.openrouter import OpenRouterProvider
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessage:
|
||||||
|
"""Tests for the Message dataclass."""
|
||||||
|
|
||||||
|
def test_message_creation(self):
|
||||||
|
"""Test creating a basic message."""
|
||||||
|
msg = Message(role="user", content="Hello")
|
||||||
|
assert msg.role == "user"
|
||||||
|
assert msg.content == "Hello"
|
||||||
|
assert msg.images == []
|
||||||
|
|
||||||
|
def test_message_with_images(self):
|
||||||
|
"""Test creating a message with images."""
|
||||||
|
images = [ImageAttachment(url="https://example.com/image.png")]
|
||||||
|
msg = Message(role="user", content="Look at this", images=images)
|
||||||
|
assert len(msg.images) == 1
|
||||||
|
assert msg.images[0].url == "https://example.com/image.png"
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageAttachment:
|
||||||
|
"""Tests for the ImageAttachment dataclass."""
|
||||||
|
|
||||||
|
def test_default_media_type(self):
|
||||||
|
"""Test default media type."""
|
||||||
|
img = ImageAttachment(url="https://example.com/image.png")
|
||||||
|
assert img.media_type == "image/png"
|
||||||
|
|
||||||
|
def test_custom_media_type(self):
|
||||||
|
"""Test custom media type."""
|
||||||
|
img = ImageAttachment(url="https://example.com/image.jpg", media_type="image/jpeg")
|
||||||
|
assert img.media_type == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAIResponse:
|
||||||
|
"""Tests for the AIResponse dataclass."""
|
||||||
|
|
||||||
|
def test_response_creation(self):
|
||||||
|
"""Test creating an AI response."""
|
||||||
|
response = AIResponse(
|
||||||
|
content="Hello!",
|
||||||
|
model="test-model",
|
||||||
|
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||||
|
)
|
||||||
|
assert response.content == "Hello!"
|
||||||
|
assert response.model == "test-model"
|
||||||
|
assert response.usage["total_tokens"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenAIProvider:
|
||||||
|
"""Tests for the OpenAI provider."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider(self, mock_openai_client):
|
||||||
|
"""Create an OpenAI provider with mocked client."""
|
||||||
|
with patch("daemon_boyfriend.services.providers.openai.AsyncOpenAI") as mock_class:
|
||||||
|
mock_class.return_value = mock_openai_client
|
||||||
|
provider = OpenAIProvider(api_key="test_key", model="gpt-4o-mini")
|
||||||
|
provider.client = mock_openai_client
|
||||||
|
return provider
|
||||||
|
|
||||||
|
def test_provider_name(self, provider):
|
||||||
|
"""Test provider name."""
|
||||||
|
assert provider.provider_name == "openai"
|
||||||
|
|
||||||
|
def test_model_setting(self, provider):
|
||||||
|
"""Test model is set correctly."""
|
||||||
|
assert provider.model == "gpt-4o-mini"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_simple_message(self, provider, mock_openai_client):
|
||||||
|
"""Test generating a response with a simple message."""
|
||||||
|
messages = [Message(role="user", content="Hello")]
|
||||||
|
|
||||||
|
response = await provider.generate(messages)
|
||||||
|
|
||||||
|
assert response.content == "Test OpenAI response"
|
||||||
|
assert response.model == "gpt-4o-mini"
|
||||||
|
mock_openai_client.chat.completions.create.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_with_system_prompt(self, provider, mock_openai_client):
|
||||||
|
"""Test generating a response with a system prompt."""
|
||||||
|
messages = [Message(role="user", content="Hello")]
|
||||||
|
|
||||||
|
await provider.generate(messages, system_prompt="You are a helpful assistant.")
|
||||||
|
|
||||||
|
call_args = mock_openai_client.chat.completions.create.call_args
|
||||||
|
api_messages = call_args.kwargs["messages"]
|
||||||
|
assert api_messages[0]["role"] == "system"
|
||||||
|
assert api_messages[0]["content"] == "You are a helpful assistant."
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_with_images(self, provider, mock_openai_client):
|
||||||
|
"""Test generating a response with images."""
|
||||||
|
images = [ImageAttachment(url="https://example.com/image.png")]
|
||||||
|
messages = [Message(role="user", content="What's in this image?", images=images)]
|
||||||
|
|
||||||
|
await provider.generate(messages)
|
||||||
|
|
||||||
|
call_args = mock_openai_client.chat.completions.create.call_args
|
||||||
|
api_messages = call_args.kwargs["messages"]
|
||||||
|
content = api_messages[0]["content"]
|
||||||
|
assert isinstance(content, list)
|
||||||
|
assert content[0]["type"] == "text"
|
||||||
|
assert content[1]["type"] == "image_url"
|
||||||
|
|
||||||
|
def test_build_message_content_no_images(self, provider):
|
||||||
|
"""Test building message content without images."""
|
||||||
|
msg = Message(role="user", content="Hello")
|
||||||
|
content = provider._build_message_content(msg)
|
||||||
|
assert content == "Hello"
|
||||||
|
|
||||||
|
def test_build_message_content_with_images(self, provider):
|
||||||
|
"""Test building message content with images."""
|
||||||
|
images = [ImageAttachment(url="https://example.com/image.png")]
|
||||||
|
msg = Message(role="user", content="Hello", images=images)
|
||||||
|
content = provider._build_message_content(msg)
|
||||||
|
assert isinstance(content, list)
|
||||||
|
assert len(content) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnthropicProvider:
|
||||||
|
"""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:
|
||||||
|
"""Tests for the Anthropic provider."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider(self, mock_anthropic_client):
|
||||||
|
"""Create an Anthropic provider with mocked client."""
|
||||||
|
with patch("daemon_boyfriend.services.providers.anthropic.anthropic.AsyncAnthropic") as mock_class:
|
||||||
|
mock_class.return_value = mock_anthropic_client
|
||||||
|
provider = AnthropicProvider(api_key="test_key", model="claude-sonnet-4-20250514")
|
||||||
|
provider.client = mock_anthropic_client
|
||||||
|
return provider
|
||||||
|
|
||||||
|
def test_provider_name(self, provider):
|
||||||
|
"""Test provider name."""
|
||||||
|
assert provider.provider_name == "anthropic"
|
||||||
|
|
||||||
|
def test_model_setting(self, provider):
|
||||||
|
"""Test model is set correctly."""
|
||||||
|
assert provider.model == "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_simple_message(self, provider, mock_anthropic_client):
|
||||||
|
"""Test generating a response with a simple message."""
|
||||||
|
messages = [Message(role="user", content="Hello")]
|
||||||
|
|
||||||
|
response = await provider.generate(messages)
|
||||||
|
|
||||||
|
assert response.content == "Test Anthropic response"
|
||||||
|
mock_anthropic_client.messages.create.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_with_system_prompt(self, provider, mock_anthropic_client):
|
||||||
|
"""Test generating a response with a system prompt."""
|
||||||
|
messages = [Message(role="user", content="Hello")]
|
||||||
|
|
||||||
|
await provider.generate(messages, system_prompt="You are a helpful assistant.")
|
||||||
|
|
||||||
|
call_args = mock_anthropic_client.messages.create.call_args
|
||||||
|
assert call_args.kwargs["system"] == "You are a helpful assistant."
|
||||||
|
|
||||||
|
def test_build_message_content_no_images(self, provider):
|
||||||
|
"""Test building message content without images."""
|
||||||
|
msg = Message(role="user", content="Hello")
|
||||||
|
content = provider._build_message_content(msg)
|
||||||
|
assert content == "Hello"
|
||||||
|
|
||||||
|
def test_build_message_content_with_images(self, provider):
|
||||||
|
"""Test building message content with images."""
|
||||||
|
images = [ImageAttachment(url="https://example.com/image.png")]
|
||||||
|
msg = Message(role="user", content="Hello", images=images)
|
||||||
|
content = provider._build_message_content(msg)
|
||||||
|
assert isinstance(content, list)
|
||||||
|
assert content[0]["type"] == "image"
|
||||||
|
assert content[1]["type"] == "text"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeminiProvider:
|
||||||
|
"""Tests for the Gemini provider."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider(self, mock_gemini_client):
|
||||||
|
"""Create a Gemini provider with mocked client."""
|
||||||
|
with patch("daemon_boyfriend.services.providers.gemini.genai.Client") as mock_class:
|
||||||
|
mock_class.return_value = mock_gemini_client
|
||||||
|
provider = GeminiProvider(api_key="test_key", model="gemini-2.0-flash")
|
||||||
|
provider.client = mock_gemini_client
|
||||||
|
return provider
|
||||||
|
|
||||||
|
def test_provider_name(self, provider):
|
||||||
|
"""Test provider name."""
|
||||||
|
assert provider.provider_name == "gemini"
|
||||||
|
|
||||||
|
def test_model_setting(self, provider):
|
||||||
|
"""Test model is set correctly."""
|
||||||
|
assert provider.model == "gemini-2.0-flash"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_simple_message(self, provider, mock_gemini_client):
|
||||||
|
"""Test generating a response with a simple message."""
|
||||||
|
messages = [Message(role="user", content="Hello")]
|
||||||
|
|
||||||
|
response = await provider.generate(messages)
|
||||||
|
|
||||||
|
assert response.content == "Test Gemini response"
|
||||||
|
mock_gemini_client.aio.models.generate_content.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_role_mapping(self, provider, mock_gemini_client):
|
||||||
|
"""Test that 'assistant' role is mapped to 'model'."""
|
||||||
|
messages = [
|
||||||
|
Message(role="user", content="Hello"),
|
||||||
|
Message(role="assistant", content="Hi there!"),
|
||||||
|
Message(role="user", content="How are you?"),
|
||||||
|
]
|
||||||
|
|
||||||
|
await provider.generate(messages)
|
||||||
|
|
||||||
|
call_args = mock_gemini_client.aio.models.generate_content.call_args
|
||||||
|
contents = call_args.kwargs["contents"]
|
||||||
|
assert contents[0].role == "user"
|
||||||
|
assert contents[1].role == "model"
|
||||||
|
assert contents[2].role == "user"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenRouterProvider:
|
||||||
|
"""Tests for the OpenRouter provider."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider(self, mock_openai_client):
|
||||||
|
"""Create an OpenRouter provider with mocked client."""
|
||||||
|
with patch("daemon_boyfriend.services.providers.openrouter.AsyncOpenAI") as mock_class:
|
||||||
|
mock_class.return_value = mock_openai_client
|
||||||
|
provider = OpenRouterProvider(api_key="test_key", model="openai/gpt-4o")
|
||||||
|
provider.client = mock_openai_client
|
||||||
|
return provider
|
||||||
|
|
||||||
|
def test_provider_name(self, provider):
|
||||||
|
"""Test provider name."""
|
||||||
|
assert provider.provider_name == "openrouter"
|
||||||
|
|
||||||
|
def test_model_setting(self, provider):
|
||||||
|
"""Test model is set correctly."""
|
||||||
|
assert provider.model == "openai/gpt-4o"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_simple_message(self, provider, mock_openai_client):
|
||||||
|
"""Test generating a response with a simple message."""
|
||||||
|
messages = [Message(role="user", content="Hello")]
|
||||||
|
|
||||||
|
response = await provider.generate(messages)
|
||||||
|
|
||||||
|
assert response.content == "Test OpenAI response"
|
||||||
|
mock_openai_client.chat.completions.create.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extra_headers(self, provider, mock_openai_client):
|
||||||
|
"""Test that OpenRouter-specific headers are included."""
|
||||||
|
messages = [Message(role="user", content="Hello")]
|
||||||
|
|
||||||
|
await provider.generate(messages)
|
||||||
|
|
||||||
|
call_args = mock_openai_client.chat.completions.create.call_args
|
||||||
|
extra_headers = call_args.kwargs.get("extra_headers", {})
|
||||||
|
assert "HTTP-Referer" in extra_headers
|
||||||
|
assert "X-Title" in extra_headers
|
||||||
620
tests/test_services.py
Normal file
620
tests/test_services.py
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
"""Tests for service layer."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from daemon_boyfriend.models import (
|
||||||
|
BotOpinion,
|
||||||
|
BotState,
|
||||||
|
Conversation,
|
||||||
|
Message,
|
||||||
|
User,
|
||||||
|
UserFact,
|
||||||
|
UserRelationship,
|
||||||
|
)
|
||||||
|
from daemon_boyfriend.services.ai_service import AIService
|
||||||
|
from daemon_boyfriend.services.fact_extraction_service import FactExtractionService
|
||||||
|
from daemon_boyfriend.services.mood_service import MoodLabel, MoodService, MoodState
|
||||||
|
from daemon_boyfriend.services.opinion_service import OpinionService, extract_topics_from_message
|
||||||
|
from daemon_boyfriend.services.persistent_conversation import PersistentConversationManager
|
||||||
|
from daemon_boyfriend.services.relationship_service import RelationshipLevel, RelationshipService
|
||||||
|
from daemon_boyfriend.services.self_awareness_service import SelfAwarenessService
|
||||||
|
from daemon_boyfriend.services.user_service import UserService
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserService:
|
||||||
|
"""Tests for UserService."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_user_new(self, db_session):
|
||||||
|
"""Test creating a new user."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
user = await service.get_or_create_user(
|
||||||
|
discord_id=123456789,
|
||||||
|
username="testuser",
|
||||||
|
display_name="Test User",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.id is not None
|
||||||
|
assert user.discord_id == 123456789
|
||||||
|
assert user.discord_username == "testuser"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_user_existing(self, db_session, sample_user):
|
||||||
|
"""Test getting an existing user."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
user = await service.get_or_create_user(
|
||||||
|
discord_id=sample_user.discord_id,
|
||||||
|
username="newname",
|
||||||
|
display_name="New Display",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.id == sample_user.id
|
||||||
|
assert user.discord_username == "newname"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_custom_name(self, db_session, sample_user):
|
||||||
|
"""Test setting a custom name."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
user = await service.set_custom_name(sample_user.discord_id, "CustomName")
|
||||||
|
|
||||||
|
assert user.custom_name == "CustomName"
|
||||||
|
assert user.display_name == "CustomName"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_custom_name(self, db_session, sample_user):
|
||||||
|
"""Test clearing a custom name."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
sample_user.custom_name = "OldName"
|
||||||
|
|
||||||
|
user = await service.set_custom_name(sample_user.discord_id, None)
|
||||||
|
|
||||||
|
assert user.custom_name is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_fact(self, db_session, sample_user):
|
||||||
|
"""Test adding a fact about a user."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
fact = await service.add_fact(
|
||||||
|
user=sample_user,
|
||||||
|
fact_type="hobby",
|
||||||
|
fact_content="likes programming",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fact.id is not None
|
||||||
|
assert fact.user_id == sample_user.id
|
||||||
|
assert fact.fact_type == "hobby"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_facts(self, db_session, sample_user_with_facts):
|
||||||
|
"""Test getting user facts."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
facts = await service.get_user_facts(sample_user_with_facts)
|
||||||
|
|
||||||
|
assert len(facts) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_facts_by_type(self, db_session, sample_user_with_facts):
|
||||||
|
"""Test getting user facts by type."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
facts = await service.get_user_facts(sample_user_with_facts, fact_type="hobby")
|
||||||
|
|
||||||
|
assert len(facts) == 1
|
||||||
|
assert facts[0].fact_type == "hobby"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_user_facts(self, db_session, sample_user_with_facts):
|
||||||
|
"""Test deleting user facts."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
count = await service.delete_user_facts(sample_user_with_facts)
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
facts = await service.get_user_facts(sample_user_with_facts, active_only=True)
|
||||||
|
assert len(facts) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_context(self, db_session, sample_user_with_facts):
|
||||||
|
"""Test getting user context string."""
|
||||||
|
service = UserService(db_session)
|
||||||
|
|
||||||
|
context = await service.get_user_context(sample_user_with_facts)
|
||||||
|
|
||||||
|
assert "Test User" in context
|
||||||
|
assert "likes programming" in context
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoodService:
|
||||||
|
"""Tests for MoodService."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_bot_state(self, db_session):
|
||||||
|
"""Test getting or creating bot state."""
|
||||||
|
service = MoodService(db_session)
|
||||||
|
|
||||||
|
state = await service.get_or_create_bot_state(guild_id=111222333)
|
||||||
|
|
||||||
|
assert state.id is not None
|
||||||
|
assert state.guild_id == 111222333
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_current_mood(self, db_session, sample_bot_state):
|
||||||
|
"""Test getting current mood."""
|
||||||
|
service = MoodService(db_session)
|
||||||
|
|
||||||
|
mood = await service.get_current_mood(guild_id=sample_bot_state.guild_id)
|
||||||
|
|
||||||
|
assert isinstance(mood, MoodState)
|
||||||
|
assert -1.0 <= mood.valence <= 1.0
|
||||||
|
assert -1.0 <= mood.arousal <= 1.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_mood(self, db_session, sample_bot_state):
|
||||||
|
"""Test updating mood."""
|
||||||
|
service = MoodService(db_session)
|
||||||
|
|
||||||
|
new_mood = await service.update_mood(
|
||||||
|
guild_id=sample_bot_state.guild_id,
|
||||||
|
sentiment_delta=0.5,
|
||||||
|
engagement_delta=0.3,
|
||||||
|
trigger_type="conversation",
|
||||||
|
trigger_description="Had a nice chat",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert new_mood.valence > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_increment_stats(self, db_session, sample_bot_state):
|
||||||
|
"""Test incrementing bot stats."""
|
||||||
|
service = MoodService(db_session)
|
||||||
|
initial_messages = sample_bot_state.total_messages_sent
|
||||||
|
|
||||||
|
await service.increment_stats(
|
||||||
|
guild_id=sample_bot_state.guild_id,
|
||||||
|
messages_sent=5,
|
||||||
|
facts_learned=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sample_bot_state.total_messages_sent == initial_messages + 5
|
||||||
|
|
||||||
|
def test_classify_mood_excited(self):
|
||||||
|
"""Test mood classification for excited."""
|
||||||
|
service = MoodService(None)
|
||||||
|
label = service._classify_mood(0.5, 0.5)
|
||||||
|
assert label == MoodLabel.EXCITED
|
||||||
|
|
||||||
|
def test_classify_mood_happy(self):
|
||||||
|
"""Test mood classification for happy."""
|
||||||
|
service = MoodService(None)
|
||||||
|
label = service._classify_mood(0.5, 0.0)
|
||||||
|
assert label == MoodLabel.HAPPY
|
||||||
|
|
||||||
|
def test_classify_mood_bored(self):
|
||||||
|
"""Test mood classification for bored."""
|
||||||
|
service = MoodService(None)
|
||||||
|
label = service._classify_mood(-0.5, 0.0)
|
||||||
|
assert label == MoodLabel.BORED
|
||||||
|
|
||||||
|
def test_classify_mood_annoyed(self):
|
||||||
|
"""Test mood classification for annoyed."""
|
||||||
|
service = MoodService(None)
|
||||||
|
label = service._classify_mood(-0.5, 0.5)
|
||||||
|
assert label == MoodLabel.ANNOYED
|
||||||
|
|
||||||
|
def test_get_mood_prompt_modifier(self):
|
||||||
|
"""Test getting mood prompt modifier."""
|
||||||
|
service = MoodService(None)
|
||||||
|
mood = MoodState(valence=0.8, arousal=0.8, label=MoodLabel.EXCITED, intensity=0.8)
|
||||||
|
|
||||||
|
modifier = service.get_mood_prompt_modifier(mood)
|
||||||
|
|
||||||
|
assert "enthusiastic" in modifier.lower() or "excited" in modifier.lower()
|
||||||
|
|
||||||
|
def test_get_mood_prompt_modifier_low_intensity(self):
|
||||||
|
"""Test mood modifier with low intensity."""
|
||||||
|
service = MoodService(None)
|
||||||
|
mood = MoodState(valence=0.1, arousal=0.1, label=MoodLabel.NEUTRAL, intensity=0.1)
|
||||||
|
|
||||||
|
modifier = service.get_mood_prompt_modifier(mood)
|
||||||
|
|
||||||
|
assert modifier == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelationshipService:
|
||||||
|
"""Tests for RelationshipService."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_relationship(self, db_session, sample_user):
|
||||||
|
"""Test getting or creating a relationship."""
|
||||||
|
service = RelationshipService(db_session)
|
||||||
|
|
||||||
|
rel = await service.get_or_create_relationship(sample_user, guild_id=111222333)
|
||||||
|
|
||||||
|
assert rel.id is not None
|
||||||
|
assert rel.user_id == sample_user.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_interaction(self, db_session, sample_user):
|
||||||
|
"""Test recording an interaction."""
|
||||||
|
service = RelationshipService(db_session)
|
||||||
|
|
||||||
|
level = await service.record_interaction(
|
||||||
|
user=sample_user,
|
||||||
|
guild_id=111222333,
|
||||||
|
sentiment=0.8,
|
||||||
|
message_length=100,
|
||||||
|
conversation_turns=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(level, RelationshipLevel)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_positive_interaction(self, db_session, sample_user):
|
||||||
|
"""Test that positive interactions are tracked."""
|
||||||
|
service = RelationshipService(db_session)
|
||||||
|
|
||||||
|
await service.record_interaction(
|
||||||
|
user=sample_user,
|
||||||
|
guild_id=111222333,
|
||||||
|
sentiment=0.5,
|
||||||
|
message_length=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
rel = await service.get_or_create_relationship(sample_user, guild_id=111222333)
|
||||||
|
assert rel.positive_interactions >= 1
|
||||||
|
|
||||||
|
def test_get_level_stranger(self):
|
||||||
|
"""Test level classification for stranger."""
|
||||||
|
service = RelationshipService(None)
|
||||||
|
assert service.get_level(10) == RelationshipLevel.STRANGER
|
||||||
|
|
||||||
|
def test_get_level_acquaintance(self):
|
||||||
|
"""Test level classification for acquaintance."""
|
||||||
|
service = RelationshipService(None)
|
||||||
|
assert service.get_level(30) == RelationshipLevel.ACQUAINTANCE
|
||||||
|
|
||||||
|
def test_get_level_friend(self):
|
||||||
|
"""Test level classification for friend."""
|
||||||
|
service = RelationshipService(None)
|
||||||
|
assert service.get_level(50) == RelationshipLevel.FRIEND
|
||||||
|
|
||||||
|
def test_get_level_good_friend(self):
|
||||||
|
"""Test level classification for good friend."""
|
||||||
|
service = RelationshipService(None)
|
||||||
|
assert service.get_level(70) == RelationshipLevel.GOOD_FRIEND
|
||||||
|
|
||||||
|
def test_get_level_close_friend(self):
|
||||||
|
"""Test level classification for close friend."""
|
||||||
|
service = RelationshipService(None)
|
||||||
|
assert service.get_level(90) == RelationshipLevel.CLOSE_FRIEND
|
||||||
|
|
||||||
|
def test_get_level_display_name(self):
|
||||||
|
"""Test getting display name for level."""
|
||||||
|
service = RelationshipService(None)
|
||||||
|
assert service.get_level_display_name(RelationshipLevel.FRIEND) == "Friend"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_relationship_info(self, db_session, sample_user_relationship, sample_user):
|
||||||
|
"""Test getting relationship info."""
|
||||||
|
service = RelationshipService(db_session)
|
||||||
|
|
||||||
|
info = await service.get_relationship_info(sample_user, guild_id=111222333)
|
||||||
|
|
||||||
|
assert "level" in info
|
||||||
|
assert "score" in info
|
||||||
|
assert "total_interactions" in info
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpinionService:
|
||||||
|
"""Tests for OpinionService."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_opinion(self, db_session):
|
||||||
|
"""Test getting or creating an opinion."""
|
||||||
|
service = OpinionService(db_session)
|
||||||
|
|
||||||
|
opinion = await service.get_or_create_opinion("programming")
|
||||||
|
|
||||||
|
assert opinion.id is not None
|
||||||
|
assert opinion.topic == "programming"
|
||||||
|
assert opinion.sentiment == 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_record_topic_discussion(self, db_session):
|
||||||
|
"""Test recording a topic discussion."""
|
||||||
|
service = OpinionService(db_session)
|
||||||
|
|
||||||
|
opinion = await service.record_topic_discussion(
|
||||||
|
topic="gaming",
|
||||||
|
guild_id=None,
|
||||||
|
sentiment=0.8,
|
||||||
|
engagement_level=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert opinion.discussion_count == 1
|
||||||
|
assert opinion.sentiment > 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_top_interests(self, db_session):
|
||||||
|
"""Test getting top interests."""
|
||||||
|
service = OpinionService(db_session)
|
||||||
|
|
||||||
|
# Create some opinions with discussions
|
||||||
|
for topic in ["programming", "gaming", "music"]:
|
||||||
|
for _ in range(5):
|
||||||
|
await service.record_topic_discussion(
|
||||||
|
topic=topic,
|
||||||
|
guild_id=None,
|
||||||
|
sentiment=0.8,
|
||||||
|
engagement_level=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
interests = await service.get_top_interests(limit=3)
|
||||||
|
|
||||||
|
assert len(interests) <= 3
|
||||||
|
|
||||||
|
def test_extract_topics_gaming(self):
|
||||||
|
"""Test extracting gaming topic."""
|
||||||
|
topics = extract_topics_from_message("I love playing video games!")
|
||||||
|
assert "gaming" in topics
|
||||||
|
|
||||||
|
def test_extract_topics_programming(self):
|
||||||
|
"""Test extracting programming topic."""
|
||||||
|
topics = extract_topics_from_message("I'm learning Python programming")
|
||||||
|
assert "programming" in topics
|
||||||
|
|
||||||
|
def test_extract_topics_multiple(self):
|
||||||
|
"""Test extracting multiple topics."""
|
||||||
|
topics = extract_topics_from_message("I code while listening to music")
|
||||||
|
assert "programming" in topics
|
||||||
|
assert "music" in topics
|
||||||
|
|
||||||
|
def test_extract_topics_none(self):
|
||||||
|
"""Test extracting no topics."""
|
||||||
|
topics = extract_topics_from_message("Hello, how are you?")
|
||||||
|
assert len(topics) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistentConversationManager:
|
||||||
|
"""Tests for PersistentConversationManager."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_conversation_new(self, db_session, sample_user):
|
||||||
|
"""Test creating a new conversation."""
|
||||||
|
manager = PersistentConversationManager(db_session)
|
||||||
|
|
||||||
|
conv = await manager.get_or_create_conversation(
|
||||||
|
user=sample_user,
|
||||||
|
channel_id=123456,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert conv.id is not None
|
||||||
|
assert conv.user_id == sample_user.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_conversation_existing(
|
||||||
|
self, db_session, sample_user, sample_conversation
|
||||||
|
):
|
||||||
|
"""Test getting an existing conversation."""
|
||||||
|
manager = PersistentConversationManager(db_session)
|
||||||
|
|
||||||
|
conv = await manager.get_or_create_conversation(
|
||||||
|
user=sample_user,
|
||||||
|
channel_id=sample_conversation.channel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert conv.id == sample_conversation.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_message(self, db_session, sample_user, sample_conversation):
|
||||||
|
"""Test adding a message."""
|
||||||
|
manager = PersistentConversationManager(db_session)
|
||||||
|
|
||||||
|
msg = await manager.add_message(
|
||||||
|
conversation=sample_conversation,
|
||||||
|
user=sample_user,
|
||||||
|
role="user",
|
||||||
|
content="Hello!",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert msg.id is not None
|
||||||
|
assert msg.content == "Hello!"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_exchange(self, db_session, sample_user, sample_conversation):
|
||||||
|
"""Test adding a user/assistant exchange."""
|
||||||
|
manager = PersistentConversationManager(db_session)
|
||||||
|
|
||||||
|
user_msg, assistant_msg = await manager.add_exchange(
|
||||||
|
conversation=sample_conversation,
|
||||||
|
user=sample_user,
|
||||||
|
user_message="Hello!",
|
||||||
|
assistant_message="Hi there!",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user_msg.role == "user"
|
||||||
|
assert assistant_msg.role == "assistant"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history(self, db_session, sample_user, sample_conversation):
|
||||||
|
"""Test getting conversation history."""
|
||||||
|
manager = PersistentConversationManager(db_session)
|
||||||
|
|
||||||
|
await manager.add_exchange(
|
||||||
|
conversation=sample_conversation,
|
||||||
|
user=sample_user,
|
||||||
|
user_message="Hello!",
|
||||||
|
assistant_message="Hi there!",
|
||||||
|
)
|
||||||
|
|
||||||
|
history = await manager.get_history(sample_conversation)
|
||||||
|
|
||||||
|
assert len(history) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_conversation(self, db_session, sample_conversation):
|
||||||
|
"""Test clearing a conversation."""
|
||||||
|
manager = PersistentConversationManager(db_session)
|
||||||
|
|
||||||
|
await manager.clear_conversation(sample_conversation)
|
||||||
|
|
||||||
|
assert sample_conversation.is_active is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelfAwarenessService:
|
||||||
|
"""Tests for SelfAwarenessService."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_bot_stats(self, db_session, sample_bot_state):
|
||||||
|
"""Test getting bot stats."""
|
||||||
|
service = SelfAwarenessService(db_session)
|
||||||
|
|
||||||
|
stats = await service.get_bot_stats(guild_id=sample_bot_state.guild_id)
|
||||||
|
|
||||||
|
assert "age_days" in stats
|
||||||
|
assert "total_messages_sent" in stats
|
||||||
|
assert "age_readable" in stats
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_history_with_user(self, db_session, sample_user, sample_user_relationship):
|
||||||
|
"""Test getting history with a user."""
|
||||||
|
service = SelfAwarenessService(db_session)
|
||||||
|
|
||||||
|
history = await service.get_history_with_user(sample_user, guild_id=111222333)
|
||||||
|
|
||||||
|
assert "days_known" in history
|
||||||
|
assert "total_interactions" in history
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reflect_on_self(self, db_session, sample_bot_state):
|
||||||
|
"""Test self reflection."""
|
||||||
|
service = SelfAwarenessService(db_session)
|
||||||
|
|
||||||
|
reflection = await service.reflect_on_self(guild_id=sample_bot_state.guild_id)
|
||||||
|
|
||||||
|
assert isinstance(reflection, str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFactExtractionService:
|
||||||
|
"""Tests for FactExtractionService."""
|
||||||
|
|
||||||
|
def test_is_extractable_short_message(self):
|
||||||
|
"""Test that short messages are not extractable."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
assert service._is_extractable("hi") is False
|
||||||
|
|
||||||
|
def test_is_extractable_greeting(self):
|
||||||
|
"""Test that greetings are not extractable."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
assert service._is_extractable("hello") is False
|
||||||
|
|
||||||
|
def test_is_extractable_command(self):
|
||||||
|
"""Test that commands are not extractable."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
assert service._is_extractable("!help me with something") is False
|
||||||
|
|
||||||
|
def test_is_extractable_valid(self):
|
||||||
|
"""Test that valid messages are extractable."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
assert (
|
||||||
|
service._is_extractable("I really enjoy programming in Python and building bots")
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_is_duplicate_exact_match(self):
|
||||||
|
"""Test duplicate detection with exact match."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
existing = {"likes programming", "enjoys gaming"}
|
||||||
|
assert service._is_duplicate("likes programming", existing) is True
|
||||||
|
|
||||||
|
def test_is_duplicate_no_match(self):
|
||||||
|
"""Test duplicate detection with no match."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
existing = {"likes programming", "enjoys gaming"}
|
||||||
|
assert service._is_duplicate("works at a tech company", existing) is False
|
||||||
|
|
||||||
|
def test_validate_fact_valid(self):
|
||||||
|
"""Test fact validation with valid fact."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
fact = {
|
||||||
|
"type": "hobby",
|
||||||
|
"content": "likes programming",
|
||||||
|
"confidence": 0.9,
|
||||||
|
}
|
||||||
|
assert service._validate_fact(fact) is True
|
||||||
|
|
||||||
|
def test_validate_fact_missing_type(self):
|
||||||
|
"""Test fact validation with missing type."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
fact = {"content": "likes programming"}
|
||||||
|
assert service._validate_fact(fact) is False
|
||||||
|
|
||||||
|
def test_validate_fact_invalid_type(self):
|
||||||
|
"""Test fact validation with invalid type."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
fact = {"type": "invalid_type", "content": "test"}
|
||||||
|
assert service._validate_fact(fact) is False
|
||||||
|
|
||||||
|
def test_validate_fact_empty_content(self):
|
||||||
|
"""Test fact validation with empty content."""
|
||||||
|
service = FactExtractionService(None)
|
||||||
|
fact = {"type": "hobby", "content": ""}
|
||||||
|
assert service._validate_fact(fact) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAIService:
|
||||||
|
"""Tests for AIService."""
|
||||||
|
|
||||||
|
def test_get_system_prompt_default(self, mock_settings):
|
||||||
|
"""Test getting default system prompt."""
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.settings", mock_settings):
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.AIService._init_provider"):
|
||||||
|
service = AIService(mock_settings)
|
||||||
|
service._provider = MagicMock()
|
||||||
|
|
||||||
|
prompt = service.get_system_prompt()
|
||||||
|
|
||||||
|
assert "TestBot" in prompt
|
||||||
|
assert "helpful and friendly" in prompt
|
||||||
|
|
||||||
|
def test_get_system_prompt_custom(self, mock_settings):
|
||||||
|
"""Test getting custom system prompt."""
|
||||||
|
mock_settings.system_prompt = "Custom prompt"
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.settings", mock_settings):
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.AIService._init_provider"):
|
||||||
|
service = AIService(mock_settings)
|
||||||
|
service._provider = MagicMock()
|
||||||
|
|
||||||
|
prompt = service.get_system_prompt()
|
||||||
|
|
||||||
|
assert prompt == "Custom prompt"
|
||||||
|
|
||||||
|
def test_provider_name(self, mock_settings):
|
||||||
|
"""Test getting provider name."""
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.settings", mock_settings):
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.AIService._init_provider"):
|
||||||
|
service = AIService(mock_settings)
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.provider_name = "openai"
|
||||||
|
service._provider = mock_provider
|
||||||
|
|
||||||
|
assert service.provider_name == "openai"
|
||||||
|
|
||||||
|
def test_model_property(self, mock_settings):
|
||||||
|
"""Test getting model name."""
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.settings", mock_settings):
|
||||||
|
with patch("daemon_boyfriend.services.ai_service.AIService._init_provider"):
|
||||||
|
service = AIService(mock_settings)
|
||||||
|
service._provider = MagicMock()
|
||||||
|
|
||||||
|
assert service.model == "gpt-4o-mini"
|
||||||
Reference in New Issue
Block a user