Files
loyal_companion/docs/living-ai/relationship-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

12 KiB

Relationship System Deep Dive

The relationship system tracks how well the bot knows each user and adjusts its behavior accordingly.

Relationship Levels

┌─────────────────────────────────────────────────────────────────────────────┐
│                         Relationship Progression                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   0 ──────── 20 ──────── 40 ──────── 60 ──────── 80 ──────── 100           │
│   │          │           │           │           │            │             │
│   │ Stranger │Acquaintance│  Friend  │Good Friend│Close Friend│             │
│   │          │           │           │           │            │             │
│   │ Polite   │ Friendly  │  Casual  │ Personal  │Very casual │             │
│   │ Formal   │ Reserved  │  Warm    │ References│Inside jokes│             │
│   │          │           │           │ past      │            │             │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Level Details

Stranger (Score 0-20)

Behavior: Polite, formal, welcoming

This is someone you don't know well yet. 
Be polite and welcoming, but keep some professional distance. 
Use more formal language.
  • Uses formal language ("Hello", not "Hey!")
  • Doesn't assume familiarity
  • Introduces itself clearly
  • Asks clarifying questions

Acquaintance (Score 21-40)

Behavior: Friendly, reserved

This is someone you've chatted with a few times. 
Be friendly and warm, but still somewhat reserved.
  • More relaxed tone
  • Uses the user's name occasionally
  • Shows memory of basic facts
  • Still maintains some distance

Friend (Score 41-60)

Behavior: Casual, warm

This is a friend! Be casual and warm. 
Use their name occasionally, show you remember past conversations.
  • Natural, conversational tone
  • References past conversations
  • Shows genuine interest
  • Comfortable with casual language

Good Friend (Score 61-80)

Behavior: Personal, references past

This is a good friend you know well. 
Be relaxed and personal. Reference things you've talked about before. 
Feel free to be playful.
  • Very comfortable tone
  • Recalls shared experiences
  • May tease gently
  • Shows deeper understanding

Close Friend (Score 81-100)

Behavior: Very casual, inside jokes

This is a close friend! Be very casual and familiar. 
Use inside jokes if you have any, be supportive and genuine. 
You can tease them gently and be more emotionally open.

Additional context for close friends:

  • "You have inside jokes together: [jokes]"
  • "You sometimes call them: [nickname]"

Score Calculation

Delta Formula

def _calculate_score_delta(sentiment, message_length, conversation_turns):
    # Base change from sentiment (-0.5 to +0.5)
    base_delta = sentiment * 0.5
    
    # Bonus for longer messages (up to +0.3)
    length_bonus = min(0.3, message_length / 500)
    
    # Bonus for deeper conversations (up to +0.2)
    depth_bonus = min(0.2, conversation_turns * 0.05)
    
    # Minimum interaction bonus (+0.1 just for talking)
    interaction_bonus = 0.1
    
    total_delta = base_delta + length_bonus + depth_bonus + interaction_bonus
    
    # Clamp to reasonable range
    return max(-1.0, min(1.0, total_delta))

Score Components

Component Range Description
Base (sentiment) -0.5 to +0.5 Positive/negative interaction quality
Length bonus 0 to +0.3 Reward for longer messages
Depth bonus 0 to +0.2 Reward for back-and-forth
Interaction bonus +0.1 Reward just for interacting

Example Calculations

Friendly chat (100 chars, positive sentiment):

base_delta = 0.4 * 0.5 = 0.2
length_bonus = min(0.3, 100/500) = 0.2
depth_bonus = 0.05
interaction_bonus = 0.1
total = 0.55 points

Short dismissive message:

base_delta = -0.3 * 0.5 = -0.15
length_bonus = min(0.3, 20/500) = 0.04
depth_bonus = 0.05
interaction_bonus = 0.1
total = 0.04 points (still positive due to interaction bonus!)

Long, deep, positive conversation:

base_delta = 0.8 * 0.5 = 0.4
length_bonus = min(0.3, 800/500) = 0.3 (capped)
depth_bonus = min(0.2, 5 * 0.05) = 0.2 (capped)
interaction_bonus = 0.1
total = 1.0 point (max)

Interaction Tracking

Each interaction records:

Metric Description
total_interactions Total number of interactions
positive_interactions Interactions with sentiment > 0.2
negative_interactions Interactions with sentiment < -0.2
avg_message_length Running average of message lengths
conversation_depth_avg Running average of turns per conversation
last_interaction_at When they last interacted
first_interaction_at When they first interacted

Running Average Formula

n = total_interactions
avg_message_length = ((avg_message_length * (n-1)) + new_length) / n

Shared References

Close relationships can have shared references:

shared_references = {
    "jokes": ["the coffee incident", "404 life not found"],
    "nicknames": ["debug buddy", "code wizard"],
    "memories": ["that time we debugged for 3 hours"]
}

Adding References

await relationship_service.add_shared_reference(
    user=user,
    guild_id=123,
    reference_type="jokes",
    content="the coffee incident"
)

Rules:

  • Maximum 10 references per type
  • Oldest are removed when limit exceeded
  • Duplicates are ignored
  • Only mentioned for Good Friend (61+) and Close Friend (81+)

Prompt Modifiers

The relationship level generates context for the AI:

Base Modifier

def get_relationship_prompt_modifier(level, relationship):
    base = BASE_MODIFIERS[level]  # Level-specific text
    
    # Add shared references for close relationships
    if level in (GOOD_FRIEND, CLOSE_FRIEND):
        if relationship.shared_references.get("jokes"):
            base += f" You have inside jokes: {jokes}"
        if relationship.shared_references.get("nicknames"):
            base += f" You sometimes call them: {nickname}"
    
    return base

Full Example Output

For a Close Friend with shared references:

This is a close friend! Be very casual and familiar. 
Use inside jokes if you have any, be supportive and genuine. 
You can tease them gently and be more emotionally open.
You have inside jokes together: the coffee incident, 404 life not found.
You sometimes call them: debug buddy.

Database Schema

UserRelationship Table

Column Type Description
id Integer Primary key
user_id Integer Foreign key to users
guild_id BigInteger Guild ID (nullable for global)
relationship_score Float 0-100 score
total_interactions Integer Total interaction count
positive_interactions Integer Count of positive interactions
negative_interactions Integer Count of negative interactions
avg_message_length Float Average message length
conversation_depth_avg Float Average turns per conversation
shared_references JSON Dictionary of shared references
first_interaction_at DateTime First interaction timestamp
last_interaction_at DateTime Last interaction timestamp

API Reference

RelationshipService

class RelationshipService:
    def __init__(self, session: AsyncSession)
    
    async def get_or_create_relationship(
        self, 
        user: User, 
        guild_id: int | None = None
    ) -> UserRelationship
    
    async def record_interaction(
        self,
        user: User,
        guild_id: int | None,
        sentiment: float,
        message_length: int,
        conversation_turns: int = 1,
    ) -> RelationshipLevel
    
    def get_level(self, score: float) -> RelationshipLevel
    
    def get_level_display_name(self, level: RelationshipLevel) -> str
    
    async def add_shared_reference(
        self, 
        user: User, 
        guild_id: int | None, 
        reference_type: str, 
        content: str
    ) -> None
    
    def get_relationship_prompt_modifier(
        self, 
        level: RelationshipLevel, 
        relationship: UserRelationship
    ) -> str
    
    async def get_relationship_info(
        self, 
        user: User, 
        guild_id: int | None = None
    ) -> dict

RelationshipLevel Enum

class RelationshipLevel(Enum):
    STRANGER = "stranger"        # 0-20
    ACQUAINTANCE = "acquaintance"  # 21-40
    FRIEND = "friend"            # 41-60
    GOOD_FRIEND = "good_friend"  # 61-80
    CLOSE_FRIEND = "close_friend"  # 81-100

Configuration

Variable Default Description
RELATIONSHIP_ENABLED true Enable/disable relationship tracking

Example Usage

from loyal_companion.services.relationship_service import RelationshipService

async with get_session() as session:
    rel_service = RelationshipService(session)
    
    # Record an interaction
    level = await rel_service.record_interaction(
        user=user,
        guild_id=123,
        sentiment=0.5,      # Positive interaction
        message_length=150,
        conversation_turns=3
    )
    print(f"Current level: {rel_service.get_level_display_name(level)}")
    
    # Get full relationship info
    info = await rel_service.get_relationship_info(user, guild_id=123)
    print(f"Score: {info['score']}")
    print(f"Total interactions: {info['total_interactions']}")
    print(f"Days known: {info['days_known']}")
    
    # Add a shared reference (for close friends)
    await rel_service.add_shared_reference(
        user=user,
        guild_id=123,
        reference_type="jokes",
        content="the infinite loop joke"
    )
    
    # Get prompt modifier
    relationship = await rel_service.get_or_create_relationship(user, 123)
    modifier = rel_service.get_relationship_prompt_modifier(level, relationship)
    print(f"Prompt modifier:\n{modifier}")
    
    await session.commit()

The !relationship Command

Users can check their relationship status:

User: !relationship

Bot: 📊 **Your Relationship with Me**

Level: **Friend** 🤝
Score: 52/100

We've had 47 conversations together!
- Positive vibes: 38 times
- Rough patches: 3 times

We've known each other for 23 days.

Design Considerations

Why Interaction Bonus?

Even negative interactions build relationship:

  • Familiarity increases even through conflict
  • Prevents relationships from degrading to zero
  • Reflects real-world relationship dynamics

Why Dampen Score Changes?

  • Prevents gaming the system with spam
  • Makes progression feel natural
  • Single interactions have limited impact
  • Requires sustained engagement to reach higher levels

Guild-Specific vs Global

  • Relationships are tracked per-guild by default
  • Allows different relationship levels in different servers
  • Set guild_id=None for global relationship tracking