diff --git a/.env.example b/.env.example index 9232789..7d17ead 100644 --- a/.env.example +++ b/.env.example @@ -29,16 +29,16 @@ AI_TEMPERATURE=0.7 # Bot Identity & Personality # =========================================== # 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) -BOT_PERSONALITY=helpful and friendly +BOT_PERSONALITY="helpful and friendly" # 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=for mentions +BOT_STATUS="for mentions" # Optional: Override the entire system prompt (leave commented to use auto-generated) # SYSTEM_PROMPT=You are a custom assistant... @@ -82,6 +82,56 @@ SEARXNG_ENABLED=true # Maximum number of search results to fetch (1-20) 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 # =========================================== @@ -111,3 +161,16 @@ LOG_LEVEL=INFO # Admin Memory: # !setusername @user - Set name for another user # !teachbot @user - 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 - 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 diff --git a/.gitignore b/.gitignore index 5b7d64d..26686c6 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,8 @@ Thumbs.db # SearXNG config (may contain secrets) searxng/settings.yml + +# Database files (if using SQLite for testing) +*.db +*.sqlite +*.sqlite3 diff --git a/README.md b/README.md index a0d40d5..cff5ce3 100644 --- a/README.md +++ b/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 - **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 ### 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.). +### 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 **Friendly Assistant:** @@ -171,6 +214,22 @@ Admin commands: | `!setusername @user ` | Set name for another user | | `!teachbot @user ` | 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 ` | 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 | Provider | Models | Notes | @@ -193,15 +252,25 @@ src/daemon_boyfriend/ ├── models/ │ ├── user.py # User, UserFact, UserPreference │ ├── conversation.py # Conversation, Message -│ └── guild.py # Guild, GuildMember +│ ├── guild.py # Guild, GuildMember +│ └── living_ai.py # BotState, UserRelationship, etc. └── services/ ├── ai_service.py # AI provider factory ├── database.py # PostgreSQL connection ├── user_service.py # User management ├── persistent_conversation.py # DB-backed history ├── providers/ # AI providers - └── searxng.py # Web search service -alembic/ # Database migrations + ├── searxng.py # Web search service + ├── 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 diff --git a/docker-compose.yml b/docker-compose.yml index 80318ae..58a5712 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,32 +1,33 @@ services: - daemon-boyfriend: - build: . - container_name: daemon-boyfriend - restart: unless-stopped - env_file: - - .env - environment: - - PYTHONUNBUFFERED=1 - - DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend - depends_on: - postgres: - condition: service_healthy + daemon-boyfriend: + build: . + container_name: daemon-boyfriend + restart: unless-stopped + env_file: + - .env + environment: + - PYTHONUNBUFFERED=1 + - DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend + depends_on: + postgres: + condition: service_healthy - postgres: - image: postgres:16-alpine - container_name: daemon-boyfriend-postgres - restart: unless-stopped - environment: - POSTGRES_USER: daemon - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon} - POSTGRES_DB: daemon_boyfriend - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"] - interval: 10s - timeout: 5s - retries: 5 + postgres: + image: postgres:16-alpine + container_name: daemon-boyfriend-postgres + restart: unless-stopped + environment: + POSTGRES_USER: daemon + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon} + POSTGRES_DB: daemon_boyfriend + volumes: + - postgres_data:/var/lib/postgresql/data + - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"] + interval: 10s + timeout: 5s + retries: 5 volumes: - postgres_data: + postgres_data: diff --git a/project-vision.md b/project-vision.md new file mode 100644 index 0000000..3e3a2e3 --- /dev/null +++ b/project-vision.md @@ -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 ` | Set your birthday for the bot to remember | `CMD_BIRTHDAY_ENABLED` | +| `!remember ` | Tell the bot something about you | `CMD_REMEMBER_ENABLED` | +| `!setname ` | 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) diff --git a/schema.sql b/schema.sql index 005de7c..4d377db 100644 --- a/schema.sql +++ b/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_user_id ON messages(user_id); 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; diff --git a/src/daemon_boyfriend/cogs/ai_chat.py b/src/daemon_boyfriend/cogs/ai_chat.py index eff3ebc..2f19eb1 100644 --- a/src/daemon_boyfriend/cogs/ai_chat.py +++ b/src/daemon_boyfriend/cogs/ai_chat.py @@ -9,13 +9,22 @@ from discord.ext import commands from daemon_boyfriend.config import settings from daemon_boyfriend.services import ( AIService, + CommunicationStyleService, ConversationManager, + FactExtractionService, ImageAttachment, Message, + MoodService, + OpinionService, PersistentConversationManager, + ProactiveService, + RelationshipService, SearXNGService, UserService, db, + detect_emoji_usage, + detect_formal_language, + extract_topics_from_message, ) from daemon_boyfriend.utils import get_monitor @@ -414,6 +423,8 @@ class AIChatCog(commands.Cog): async with db.session() as session: user_service = UserService(session) conv_manager = PersistentConversationManager(session) + mood_service = MoodService(session) + relationship_service = RelationshipService(session) # 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, ) + guild_id = message.guild.id if message.guild else None + # Get or create conversation conversation = await conv_manager.get_or_create_conversation( user=user, - guild_id=message.guild.id if message.guild else None, + guild_id=guild_id, channel_id=message.channel.id, ) @@ -446,8 +459,43 @@ class AIChatCog(commands.Cog): # Get context about mentioned users mentioned_users_context = self._get_mentioned_users_context(message) - # Build system prompt with additional context - system_prompt = self.ai_service.get_system_prompt() + # Get Living AI context (mood, relationship, style, opinions) + 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) user_context = await user_service.get_user_context(user) @@ -482,6 +530,20 @@ class AIChatCog(commands.Cog): 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( f"Generated response for user {user.discord_id}: " f"{len(response.content)} chars, {response.usage}" @@ -489,6 +551,171 @@ class AIChatCog(commands.Cog): 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( self, message: discord.Message, user_message: str ) -> str: diff --git a/src/daemon_boyfriend/config.py b/src/daemon_boyfriend/config.py index e9c6131..4054741 100644 --- a/src/daemon_boyfriend/config.py +++ b/src/daemon_boyfriend/config.py @@ -87,6 +87,38 @@ class Settings(BaseSettings): 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") + # 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: """Get the API key for the configured provider.""" key_map = { diff --git a/src/daemon_boyfriend/models/__init__.py b/src/daemon_boyfriend/models/__init__.py index 4791377..a592d7e 100644 --- a/src/daemon_boyfriend/models/__init__.py +++ b/src/daemon_boyfriend/models/__init__.py @@ -3,15 +3,31 @@ from .base import Base from .conversation import Conversation, Message from .guild import Guild, GuildMember +from .living_ai import ( + BotOpinion, + BotState, + FactAssociation, + MoodHistory, + ScheduledEvent, + UserCommunicationStyle, + UserRelationship, +) from .user import User, UserFact, UserPreference __all__ = [ "Base", + "BotOpinion", + "BotState", "Conversation", + "FactAssociation", "Guild", "GuildMember", "Message", + "MoodHistory", + "ScheduledEvent", "User", + "UserCommunicationStyle", "UserFact", "UserPreference", + "UserRelationship", ] diff --git a/src/daemon_boyfriend/models/base.py b/src/daemon_boyfriend/models/base.py index 407ce98..cf19f41 100644 --- a/src/daemon_boyfriend/models/base.py +++ b/src/daemon_boyfriend/models/base.py @@ -1,11 +1,17 @@ """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.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) convention = { "ix": "ix_%(column_0_label)s", @@ -23,6 +29,8 @@ class Base(AsyncAttrs, DeclarativeBase): metadata = metadata - # Common timestamp columns - created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) - updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow) + # Common timestamp columns - use timezone-aware datetimes + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, onupdate=utc_now + ) diff --git a/src/daemon_boyfriend/models/conversation.py b/src/daemon_boyfriend/models/conversation.py index 5b0b0f7..4c23c4e 100644 --- a/src/daemon_boyfriend/models/conversation.py +++ b/src/daemon_boyfriend/models/conversation.py @@ -3,10 +3,10 @@ from datetime import datetime 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 .base import Base +from .base import Base, utc_now if TYPE_CHECKING: from .user import User @@ -21,8 +21,10 @@ class Conversation(Base): user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) guild_id: Mapped[int | None] = mapped_column(BigInteger) channel_id: Mapped[int | None] = mapped_column(BigInteger, index=True) - started_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) - last_message_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, index=True) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + last_message_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, index=True + ) message_count: Mapped[int] = mapped_column(Integer, default=0) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) diff --git a/src/daemon_boyfriend/models/guild.py b/src/daemon_boyfriend/models/guild.py index df42f52..84539c4 100644 --- a/src/daemon_boyfriend/models/guild.py +++ b/src/daemon_boyfriend/models/guild.py @@ -3,11 +3,20 @@ from datetime import datetime 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.orm import Mapped, mapped_column, relationship -from .base import Base +from .base import Base, utc_now if TYPE_CHECKING: from .user import User @@ -21,7 +30,7 @@ class Guild(Base): id: Mapped[int] = mapped_column(primary_key=True) discord_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) 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) 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) guild_nickname: Mapped[str | None] = mapped_column(String(255)) 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 guild: Mapped["Guild"] = relationship(back_populates="members") diff --git a/src/daemon_boyfriend/models/living_ai.py b/src/daemon_boyfriend/models/living_ai.py new file mode 100644 index 0000000..7203011 --- /dev/null +++ b/src/daemon_boyfriend/models/living_ai.py @@ -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 + ) diff --git a/src/daemon_boyfriend/models/user.py b/src/daemon_boyfriend/models/user.py index 00b44bd..987b25d 100644 --- a/src/daemon_boyfriend/models/user.py +++ b/src/daemon_boyfriend/models/user.py @@ -3,14 +3,24 @@ from datetime import datetime 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 .base import Base +from .base import Base, utc_now if TYPE_CHECKING: from .conversation import Conversation, Message from .guild import GuildMember + from .living_ai import ScheduledEvent, UserCommunicationStyle, UserRelationship class User(Base): @@ -23,8 +33,8 @@ class User(Base): discord_username: Mapped[str] = mapped_column(String(255)) discord_display_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) - last_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(DateTime(timezone=True), default=utc_now) is_active: Mapped[bool] = mapped_column(Boolean, default=True) # Relationships @@ -42,6 +52,17 @@ class User(Base): 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 def display_name(self) -> str: """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) source: Mapped[str] = mapped_column(String(50), default="conversation") is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) - learned_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) - last_referenced_at: Mapped[datetime | None] = mapped_column(default=None) + learned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + last_referenced_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), default=None + ) # Relationships user: Mapped["User"] = relationship(back_populates="facts") diff --git a/src/daemon_boyfriend/services/__init__.py b/src/daemon_boyfriend/services/__init__.py index 44f9713..c5bc93c 100644 --- a/src/daemon_boyfriend/services/__init__.py +++ b/src/daemon_boyfriend/services/__init__.py @@ -1,23 +1,49 @@ """Services for external integrations.""" 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 .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 .proactive_service import ProactiveService from .providers import AIResponse, ImageAttachment, Message +from .relationship_service import RelationshipLevel, RelationshipService from .searxng import SearXNGService +from .self_awareness_service import SelfAwarenessService from .user_service import UserService __all__ = [ "AIService", "AIResponse", + "AssociationService", + "CommunicationStyleService", "ConversationManager", "DatabaseService", + "FactExtractionService", "ImageAttachment", "Message", + "MoodLabel", + "MoodService", + "MoodState", + "OpinionService", "PersistentConversationManager", + "ProactiveService", + "RelationshipLevel", + "RelationshipService", "SearXNGService", + "SelfAwarenessService", "UserService", "db", + "detect_emoji_usage", + "detect_formal_language", + "extract_topics_from_message", "get_db", ] diff --git a/src/daemon_boyfriend/services/ai_service.py b/src/daemon_boyfriend/services/ai_service.py index 775ca28..11c5482 100644 --- a/src/daemon_boyfriend/services/ai_service.py +++ b/src/daemon_boyfriend/services/ai_service.py @@ -1,7 +1,9 @@ """AI Service - Factory and facade for AI providers.""" +from __future__ import annotations + import logging -from typing import Literal +from typing import TYPE_CHECKING, Literal from daemon_boyfriend.config import Settings, settings @@ -15,6 +17,12 @@ from .providers import ( 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__) ProviderType = Literal["openai", "openrouter", "anthropic", "gemini"] @@ -106,3 +114,90 @@ class AIService: f"Discord bot. Keep your responses concise and engaging. " 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) diff --git a/src/daemon_boyfriend/services/association_service.py b/src/daemon_boyfriend/services/association_service.py new file mode 100644 index 0000000..e846a12 --- /dev/null +++ b/src/daemon_boyfriend/services/association_service.py @@ -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 diff --git a/src/daemon_boyfriend/services/communication_style_service.py b/src/daemon_boyfriend/services/communication_style_service.py new file mode 100644 index 0000000..bf20b74 --- /dev/null +++ b/src/daemon_boyfriend/services/communication_style_service.py @@ -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 diff --git a/src/daemon_boyfriend/services/fact_extraction_service.py b/src/daemon_boyfriend/services/fact_extraction_service.py new file mode 100644 index 0000000..4d10fcb --- /dev/null +++ b/src/daemon_boyfriend/services/fact_extraction_service.py @@ -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 diff --git a/src/daemon_boyfriend/services/mood_service.py b/src/daemon_boyfriend/services/mood_service.py new file mode 100644 index 0000000..622342e --- /dev/null +++ b/src/daemon_boyfriend/services/mood_service.py @@ -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 ''}" diff --git a/src/daemon_boyfriend/services/opinion_service.py b/src/daemon_boyfriend/services/opinion_service.py new file mode 100644 index 0000000..115186e --- /dev/null +++ b/src/daemon_boyfriend/services/opinion_service.py @@ -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 diff --git a/src/daemon_boyfriend/services/persistent_conversation.py b/src/daemon_boyfriend/services/persistent_conversation.py index 3c46d15..84771bd 100644 --- a/src/daemon_boyfriend/services/persistent_conversation.py +++ b/src/daemon_boyfriend/services/persistent_conversation.py @@ -1,7 +1,7 @@ """Persistent conversation management using PostgreSQL.""" import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -38,7 +38,7 @@ class PersistentConversationManager: Conversation model instance """ # Look for recent active conversation in this channel - cutoff = datetime.utcnow() - self._timeout + cutoff = datetime.now(timezone.utc) - self._timeout stmt = select(Conversation).where( Conversation.user_id == user.id, @@ -133,7 +133,7 @@ class PersistentConversationManager: self._session.add(message) # Update conversation stats - conversation.last_message_at = datetime.utcnow() + conversation.last_message_at = datetime.now(timezone.utc) conversation.message_count += 1 await self._session.flush() diff --git a/src/daemon_boyfriend/services/proactive_service.py b/src/daemon_boyfriend/services/proactive_service.py new file mode 100644 index 0000000..c7e6e0a --- /dev/null +++ b/src/daemon_boyfriend/services/proactive_service.py @@ -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": , "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 diff --git a/src/daemon_boyfriend/services/relationship_service.py b/src/daemon_boyfriend/services/relationship_service.py new file mode 100644 index 0000000..a0339e2 --- /dev/null +++ b/src/daemon_boyfriend/services/relationship_service.py @@ -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 {}, + } diff --git a/src/daemon_boyfriend/services/self_awareness_service.py b/src/daemon_boyfriend/services/self_awareness_service.py new file mode 100644 index 0000000..4ce4bf4 --- /dev/null +++ b/src/daemon_boyfriend/services/self_awareness_service.py @@ -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 ''}" diff --git a/src/daemon_boyfriend/services/user_service.py b/src/daemon_boyfriend/services/user_service.py index d06c98b..b16900b 100644 --- a/src/daemon_boyfriend/services/user_service.py +++ b/src/daemon_boyfriend/services/user_service.py @@ -1,7 +1,7 @@ """User management service.""" import logging -from datetime import datetime +from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -40,7 +40,7 @@ class UserService: if user: # Update last seen and current name - user.last_seen_at = datetime.utcnow() + user.last_seen_at = datetime.now(timezone.utc) user.discord_username = username if 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 lines.append(f"- [{fact.fact_type}] {fact.fact_content}") # Mark as referenced - fact.last_referenced_at = datetime.utcnow() + fact.last_referenced_at = datetime.now(timezone.utc) return "\n".join(lines) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..901f328 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for Daemon Boyfriend Discord bot.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..86cfc36 --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..8ffd5b4 --- /dev/null +++ b/tests/test_models.py @@ -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 diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..c1b4ed0 --- /dev/null +++ b/tests/test_providers.py @@ -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 diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..f2487eb --- /dev/null +++ b/tests/test_services.py @@ -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"