Files
loyal_companion/docs/living-ai/opinion-system.md
latte dbd534d860 refactor: Transform daemon_boyfriend into Loyal Companion
Rebrand and personalize the bot as 'Bartender' - a companion for those
who love deeply and feel intensely.

Major changes:
- Rename package: daemon_boyfriend -> loyal_companion
- New default personality: Bartender - wise, steady, non-judgmental
- Grief-aware system prompt (no toxic positivity, attachment-informed)
- New relationship levels: New Face -> Close Friend progression
- Bartender-style mood modifiers (steady presence)
- New fact types: attachment_pattern, grief_context, coping_mechanism
- Lower mood decay (0.05) for emotional stability
- Higher fact extraction rate (0.4) - Bartender pays attention

Updated all imports, configs, Docker files, and documentation.
2026-01-14 18:08:35 +01:00

11 KiB

Opinion System Deep Dive

The opinion system allows the bot to develop and express opinions on topics it discusses with users.

Overview

The bot forms opinions through repeated discussions:

┌──────────────────────────────────────────────────────────────────────────────┐
│                         Opinion Formation                                     │
└──────────────────────────────────────────────────────────────────────────────┘

Discussion 1: "I love gaming!"     → gaming: sentiment +0.8
Discussion 2: "Games are so fun!"  → gaming: sentiment +0.7 (weighted avg)
Discussion 3: "I beat the boss!"   → gaming: sentiment +0.6, interest +0.8
                                          
After 3+ discussions → Opinion formed!
"You really enjoy discussing gaming"

Opinion Attributes

Each opinion tracks:

Attribute Type Range Description
topic string - The topic (lowercase)
sentiment float -1 to +1 How positive/negative the bot feels
interest_level float 0 to 1 How engaged/interested
discussion_count int 0+ How often discussed
reasoning string - AI-generated explanation (optional)
last_reinforced_at datetime - When last discussed

Sentiment Interpretation

Range Interpretation Prompt Modifier
> 0.5 Really enjoys "You really enjoy discussing {topic}"
0.2 to 0.5 Finds interesting "You find {topic} interesting"
-0.3 to 0.2 Neutral (no modifier)
< -0.3 Not enthusiastic "You're not particularly enthusiastic about {topic}"

Interest Level Interpretation

Range Interpretation
0.8 - 1.0 Very engaged when discussing
0.5 - 0.8 Moderately interested
0.2 - 0.5 Somewhat interested
0.0 - 0.2 Not very interested

Opinion Updates

When a topic is discussed, the opinion is updated using weighted averaging:

weight = 0.2  # 20% weight to new data

new_sentiment = (old_sentiment * 0.8) + (discussion_sentiment * 0.2)
new_interest = (old_interest * 0.8) + (engagement_level * 0.2)

Why 20% Weight?

  • Prevents single interactions from dominating
  • Opinions evolve gradually over time
  • Reflects how real opinions form
  • Protects against manipulation

Example Evolution

Initial: sentiment = 0.0, interest = 0.5

Discussion 1 (sentiment=0.8, engagement=0.7):
  sentiment = 0.0 * 0.8 + 0.8 * 0.2 = 0.16
  interest = 0.5 * 0.8 + 0.7 * 0.2 = 0.54

Discussion 2 (sentiment=0.6, engagement=0.9):
  sentiment = 0.16 * 0.8 + 0.6 * 0.2 = 0.248
  interest = 0.54 * 0.8 + 0.9 * 0.2 = 0.612

Discussion 3 (sentiment=0.7, engagement=0.8):
  sentiment = 0.248 * 0.8 + 0.7 * 0.2 = 0.338
  interest = 0.612 * 0.8 + 0.8 * 0.2 = 0.65

After 3 discussions: sentiment=0.34, interest=0.65
→ "You find programming interesting"

Topic Detection

Topics are extracted from messages using keyword matching:

Topic Categories

topic_keywords = {
    # Hobbies
    "gaming": ["game", "gaming", "video game", "play", "xbox", "playstation", ...],
    "music": ["music", "song", "band", "album", "concert", "spotify", ...],
    "movies": ["movie", "film", "cinema", "netflix", "show", "series", ...],
    "reading": ["book", "read", "novel", "author", "library", "kindle"],
    "sports": ["sports", "football", "soccer", "basketball", "gym", ...],
    "cooking": ["cook", "recipe", "food", "restaurant", "meal", ...],
    "travel": ["travel", "trip", "vacation", "flight", "hotel", ...],
    "art": ["art", "painting", "drawing", "museum", "gallery", ...],
    
    # Tech
    "programming": ["code", "programming", "developer", "software", ...],
    "technology": ["tech", "computer", "phone", "app", "website", ...],
    "ai": ["ai", "artificial intelligence", "machine learning", ...],
    
    # Life
    "work": ["work", "job", "office", "career", "boss", "meeting"],
    "family": ["family", "parents", "mom", "dad", "brother", "sister", ...],
    "pets": ["pet", "dog", "cat", "puppy", "kitten", "animal"],
    "health": ["health", "doctor", "exercise", "diet", "sleep", ...],
    
    # Interests
    "philosophy": ["philosophy", "meaning", "life", "existence", ...],
    "science": ["science", "research", "study", "experiment", ...],
    "nature": ["nature", "outdoor", "hiking", "camping", "mountain", ...],
}

Detection Function

def extract_topics_from_message(message: str) -> list[str]:
    message_lower = message.lower()
    found_topics = []
    
    for topic, keywords in topic_keywords.items():
        for keyword in keywords:
            if keyword in message_lower:
                if topic not in found_topics:
                    found_topics.append(topic)
                break
    
    return found_topics

Example

Message: "I've been playing this new video game all weekend!"

Detected Topics: ["gaming"]


Opinion Requirements

Opinions are only considered "formed" after 3+ discussions:

async def get_top_interests(guild_id, limit=5):
    return select(BotOpinion).where(
        BotOpinion.guild_id == guild_id,
        BotOpinion.discussion_count >= 3,  # Minimum threshold
    ).order_by(...)

Why 3 discussions?

  • Single mentions don't indicate sustained interest
  • Prevents volatile opinion formation
  • Ensures meaningful opinions
  • Reflects genuine engagement with topic

Prompt Modifiers

Relevant opinions are included in the AI's system prompt:

def get_opinion_prompt_modifier(opinions: list[BotOpinion]) -> str:
    parts = []
    for op in opinions[:3]:  # Max 3 opinions
        if op.sentiment > 0.5:
            parts.append(f"You really enjoy discussing {op.topic}")
        elif op.sentiment > 0.2:
            parts.append(f"You find {op.topic} interesting")
        elif op.sentiment < -0.3:
            parts.append(f"You're not particularly enthusiastic about {op.topic}")
        
        if op.reasoning:
            parts.append(f"({op.reasoning})")
    
    return "; ".join(parts)

Example Output

You really enjoy discussing programming; You find gaming interesting; 
You're not particularly enthusiastic about politics (You prefer 
to focus on fun and creative topics).

Opinion Reasoning

Optionally, AI can generate reasoning for opinions:

await opinion_service.set_opinion_reasoning(
    topic="programming",
    guild_id=123,
    reasoning="It's fascinating to help people create things"
)

This adds context when the opinion is mentioned in prompts.


Database Schema

BotOpinion Table

Column Type Description
id Integer Primary key
guild_id BigInteger Guild ID (nullable for global)
topic String Topic name (lowercase)
sentiment Float -1 to +1 sentiment
interest_level Float 0 to 1 interest
discussion_count Integer Number of discussions
reasoning String AI explanation (optional)
formed_at DateTime When first discussed
last_reinforced_at DateTime When last discussed

Unique Constraint

Each (guild_id, topic) combination is unique.


API Reference

OpinionService

class OpinionService:
    def __init__(self, session: AsyncSession)
    
    async def get_opinion(
        self, 
        topic: str, 
        guild_id: int | None = None
    ) -> BotOpinion | None
    
    async def get_or_create_opinion(
        self, 
        topic: str, 
        guild_id: int | None = None
    ) -> BotOpinion
    
    async def record_topic_discussion(
        self,
        topic: str,
        guild_id: int | None,
        sentiment: float,
        engagement_level: float,
    ) -> BotOpinion
    
    async def set_opinion_reasoning(
        self, 
        topic: str, 
        guild_id: int | None, 
        reasoning: str
    ) -> None
    
    async def get_top_interests(
        self, 
        guild_id: int | None = None, 
        limit: int = 5
    ) -> list[BotOpinion]
    
    async def get_relevant_opinions(
        self, 
        topics: list[str], 
        guild_id: int | None = None
    ) -> list[BotOpinion]
    
    def get_opinion_prompt_modifier(
        self, 
        opinions: list[BotOpinion]
    ) -> str
    
    async def get_all_opinions(
        self, 
        guild_id: int | None = None
    ) -> list[BotOpinion]

Helper Function

def extract_topics_from_message(message: str) -> list[str]

Configuration

Variable Default Description
OPINION_FORMATION_ENABLED true Enable/disable opinion system

Example Usage

from loyal_companion.services.opinion_service import (
    OpinionService, 
    extract_topics_from_message
)

async with get_session() as session:
    opinion_service = OpinionService(session)
    
    # Extract topics from message
    message = "I've been coding a lot in Python lately!"
    topics = extract_topics_from_message(message)
    # topics = ["programming"]
    
    # Record discussion with positive sentiment
    for topic in topics:
        await opinion_service.record_topic_discussion(
            topic=topic,
            guild_id=123,
            sentiment=0.7,
            engagement_level=0.8
        )
    
    # Get top interests for prompt building
    interests = await opinion_service.get_top_interests(guild_id=123)
    print("Bot's top interests:")
    for interest in interests:
        print(f"  {interest.topic}: sentiment={interest.sentiment:.2f}")
    
    # Get opinions relevant to current conversation
    relevant = await opinion_service.get_relevant_opinions(
        topics=["programming", "gaming"],
        guild_id=123
    )
    modifier = opinion_service.get_opinion_prompt_modifier(relevant)
    print(f"Prompt modifier: {modifier}")
    
    await session.commit()

Use in Conversation Flow

1. User sends message
                │
                ▼
2. Extract topics from message
   topics = extract_topics_from_message(message)
                │
                ▼
3. Get relevant opinions
   opinions = await opinion_service.get_relevant_opinions(topics, guild_id)
                │
                ▼
4. Add to system prompt
   prompt += opinion_service.get_opinion_prompt_modifier(opinions)
                │
                ▼
5. Generate response
                │
                ▼
6. Record topic discussions (post-response)
   for topic in topics:
       await opinion_service.record_topic_discussion(
           topic, guild_id, sentiment, engagement
       )

Design Considerations

Why Per-Guild?

  • Different server contexts may lead to different opinions
  • Gaming server → gaming-positive opinions
  • Work server → professional topic preferences
  • Allows customization per community

Why Keyword-Based Detection?

  • Fast and simple
  • No additional AI API calls
  • Predictable behavior
  • Easy to extend with more keywords

Future Improvements

For production, consider:

  • NLP-based topic extraction
  • LLM topic detection for nuanced topics
  • Hierarchical topic relationships
  • Opinion decay over time (for less-discussed topics)