Files
loyal_companion/docs/database.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

571 lines
20 KiB
Markdown

# Database Schema Guide
This document describes the database schema used by Daemon Boyfriend.
## Table of Contents
- [Overview](#overview)
- [Core Tables](#core-tables)
- [Living AI Tables](#living-ai-tables)
- [Indexes](#indexes)
- [Relationships](#relationships)
- [Schema Diagram](#schema-diagram)
---
## Overview
### Database Support
- **Primary:** PostgreSQL with async support (`asyncpg`)
- **Fallback:** In-memory (no persistence)
- **Testing:** SQLite (via `aiosqlite`)
### Key Features
- **PortableJSON:** Custom type for PostgreSQL JSONB / SQLite JSON compatibility
- **UTC Timestamps:** All timestamps are stored as timezone-aware UTC
- **Soft Deletes:** Many tables use `is_active` flag instead of hard deletes
- **Cascade Deletes:** Foreign keys cascade on user/conversation deletion
### Connection Configuration
```bash
# PostgreSQL connection string
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/loyal_companion
# Optional settings
DATABASE_ECHO=false # Log SQL queries
DATABASE_POOL_SIZE=5 # Connection pool size
DATABASE_MAX_OVERFLOW=10 # Max connections beyond pool
```
---
## Core Tables
### users
Stores Discord user information.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `discord_id` | BIGINT | Discord user ID (unique) |
| `discord_username` | VARCHAR(255) | Discord username |
| `discord_display_name` | VARCHAR(255) | Discord display name |
| `custom_name` | VARCHAR(255) | Custom name set by user/admin |
| `first_seen_at` | TIMESTAMPTZ | When user was first seen |
| `last_seen_at` | TIMESTAMPTZ | When user was last seen |
| `is_active` | BOOLEAN | Whether user is active |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Usage:**
```python
user = await user_service.get_or_create_user(
discord_id=123456789,
username="john_doe",
display_name="John"
)
print(user.display_name) # Returns custom_name or discord_display_name
```
---
### user_preferences
Key-value storage for user preferences.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `preference_key` | VARCHAR(100) | Preference name |
| `preference_value` | TEXT | Preference value |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(user_id, preference_key)`
---
### user_facts
Facts the bot knows about users.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `fact_type` | VARCHAR(50) | Type (hobby, work, family, etc.) |
| `fact_content` | TEXT | The fact itself |
| `confidence` | FLOAT | 0.0-1.0 confidence level |
| `source` | VARCHAR(50) | How learned (conversation, auto_extraction, manual) |
| `is_active` | BOOLEAN | Whether fact is active |
| `learned_at` | TIMESTAMPTZ | When fact was learned |
| `last_referenced_at` | TIMESTAMPTZ | When fact was last used |
| `category` | VARCHAR(50) | Category (same as fact_type) |
| `importance` | FLOAT | 0.0-1.0 importance level |
| `temporal_relevance` | VARCHAR(20) | past/present/future/timeless |
| `expiry_date` | TIMESTAMPTZ | When fact expires (optional) |
| `extracted_from_message_id` | BIGINT | Source Discord message ID |
| `extraction_context` | TEXT | Context of extraction |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Fact Types:**
- `hobby` - Activities, interests
- `work` - Job, career
- `family` - Family members
- `preference` - Likes, dislikes
- `location` - Places
- `event` - Life events
- `relationship` - Personal relationships
- `general` - Other facts
---
### guilds
Discord server (guild) information.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `discord_id` | BIGINT | Discord guild ID (unique) |
| `name` | VARCHAR(255) | Guild name |
| `joined_at` | TIMESTAMPTZ | When bot joined |
| `is_active` | BOOLEAN | Whether bot is active in guild |
| `settings` | JSONB | Guild-specific settings |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### guild_members
User membership in guilds.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Foreign key to guilds |
| `user_id` | BIGINT | Foreign key to users |
| `guild_nickname` | VARCHAR(255) | Nickname in this guild |
| `roles` | TEXT[] | Array of role names |
| `joined_guild_at` | TIMESTAMPTZ | When user joined guild |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(guild_id, user_id)`
---
### conversations
Conversation sessions between users and the bot.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `guild_id` | BIGINT | Guild ID (nullable for DMs) |
| `channel_id` | BIGINT | Discord channel ID |
| `started_at` | TIMESTAMPTZ | When conversation started |
| `last_message_at` | TIMESTAMPTZ | Last message timestamp |
| `message_count` | INTEGER | Number of messages |
| `is_active` | BOOLEAN | Whether conversation is active |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Conversation Timeout:**
A new conversation starts after 60 minutes of inactivity.
---
### messages
Individual messages in conversations.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `conversation_id` | BIGINT | Foreign key to conversations |
| `user_id` | BIGINT | Foreign key to users |
| `discord_message_id` | BIGINT | Discord message ID |
| `role` | VARCHAR(20) | user/assistant/system |
| `content` | TEXT | Message content |
| `has_images` | BOOLEAN | Whether message has images |
| `image_urls` | JSONB | Array of image URLs |
| `token_count` | INTEGER | Token count (estimated) |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
## Living AI Tables
### bot_states
Per-guild bot state (mood, statistics).
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Guild ID (unique, NULL = global) |
| `mood_valence` | FLOAT | -1.0 to 1.0 (sad to happy) |
| `mood_arousal` | FLOAT | -1.0 to 1.0 (calm to excited) |
| `mood_updated_at` | TIMESTAMPTZ | When mood was last updated |
| `total_messages_sent` | INTEGER | Lifetime message count |
| `total_facts_learned` | INTEGER | Facts extracted count |
| `total_users_known` | INTEGER | Unique users count |
| `first_activated_at` | TIMESTAMPTZ | Bot "birth date" |
| `preferences` | JSONB | Bot preferences |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### bot_opinions
Bot's opinions on topics.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Guild ID (NULL = global) |
| `topic` | VARCHAR(255) | Topic name (lowercase) |
| `sentiment` | FLOAT | -1.0 to 1.0 |
| `interest_level` | FLOAT | 0.0 to 1.0 |
| `discussion_count` | INTEGER | Times discussed |
| `reasoning` | TEXT | AI explanation (optional) |
| `formed_at` | TIMESTAMPTZ | When opinion formed |
| `last_reinforced_at` | TIMESTAMPTZ | When last discussed |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(guild_id, topic)`
---
### user_relationships
Relationship depth with each user.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `guild_id` | BIGINT | Guild ID (NULL = global) |
| `relationship_score` | FLOAT | 0-100 score |
| `total_interactions` | INTEGER | Total interaction count |
| `positive_interactions` | INTEGER | Positive interaction count |
| `negative_interactions` | INTEGER | Negative interaction count |
| `avg_message_length` | FLOAT | Average message length |
| `conversation_depth_avg` | FLOAT | Average conversation turns |
| `shared_references` | JSONB | Inside jokes, nicknames, etc. |
| `first_interaction_at` | TIMESTAMPTZ | First interaction |
| `last_interaction_at` | TIMESTAMPTZ | Last interaction |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
**Unique Constraint:** `(user_id, guild_id)`
**Relationship Score Levels:**
- 0-20: Stranger
- 21-40: Acquaintance
- 41-60: Friend
- 61-80: Good Friend
- 81-100: Close Friend
---
### user_communication_styles
Learned communication preferences per user.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users (unique) |
| `preferred_length` | VARCHAR(20) | short/medium/long |
| `preferred_formality` | FLOAT | 0.0 (casual) to 1.0 (formal) |
| `emoji_affinity` | FLOAT | 0.0 (none) to 1.0 (lots) |
| `humor_affinity` | FLOAT | 0.0 (serious) to 1.0 (playful) |
| `detail_preference` | FLOAT | 0.0 (concise) to 1.0 (detailed) |
| `engagement_signals` | JSONB | Engagement signal data |
| `samples_collected` | INTEGER | Number of samples analyzed |
| `confidence` | FLOAT | 0.0-1.0 confidence level |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### scheduled_events
Proactive events (birthdays, follow-ups).
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `user_id` | BIGINT | Foreign key to users |
| `guild_id` | BIGINT | Target guild ID |
| `channel_id` | BIGINT | Target channel ID |
| `event_type` | VARCHAR(50) | birthday/follow_up/reminder |
| `trigger_at` | TIMESTAMPTZ | When to trigger |
| `title` | VARCHAR(255) | Event title |
| `context` | JSONB | Additional context |
| `is_recurring` | BOOLEAN | Whether event recurs |
| `recurrence_rule` | VARCHAR(100) | yearly/monthly/weekly/etc. |
| `status` | VARCHAR(20) | pending/triggered/cancelled |
| `triggered_at` | TIMESTAMPTZ | When event was triggered |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
### fact_associations
Cross-user fact associations.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `fact_id_1` | BIGINT | Foreign key to user_facts |
| `fact_id_2` | BIGINT | Foreign key to user_facts |
| `association_type` | VARCHAR(50) | shared_interest/same_location/etc. |
| `strength` | FLOAT | 0.0-1.0 association strength |
| `discovered_at` | TIMESTAMPTZ | When discovered |
| `created_at` | TIMESTAMPTZ | Record creation time |
**Unique Constraint:** `(fact_id_1, fact_id_2)`
---
### mood_history
Historical mood changes.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL | Primary key |
| `guild_id` | BIGINT | Guild ID |
| `valence` | FLOAT | Valence at this point |
| `arousal` | FLOAT | Arousal at this point |
| `trigger_type` | VARCHAR(50) | conversation/time_decay/event |
| `trigger_user_id` | BIGINT | User who triggered (optional) |
| `trigger_description` | TEXT | Description of trigger |
| `recorded_at` | TIMESTAMPTZ | When recorded |
| `created_at` | TIMESTAMPTZ | Record creation time |
| `updated_at` | TIMESTAMPTZ | Record update time |
---
## Indexes
All indexes are created with `IF NOT EXISTS` for idempotency.
### Core Table Indexes
| Table | Index | Columns |
|-------|-------|---------|
| users | ix_users_discord_id | discord_id |
| users | ix_users_last_seen_at | last_seen_at |
| user_preferences | ix_user_preferences_user_id | user_id |
| user_facts | ix_user_facts_user_id | user_id |
| user_facts | ix_user_facts_fact_type | fact_type |
| user_facts | ix_user_facts_is_active | is_active |
| guilds | ix_guilds_discord_id | discord_id |
| guild_members | ix_guild_members_guild_id | guild_id |
| guild_members | ix_guild_members_user_id | user_id |
| conversations | ix_conversations_user_id | user_id |
| conversations | ix_conversations_channel_id | channel_id |
| conversations | ix_conversations_last_message_at | last_message_at |
| conversations | ix_conversations_is_active | is_active |
| messages | ix_messages_conversation_id | conversation_id |
| messages | ix_messages_user_id | user_id |
| messages | ix_messages_created_at | created_at |
### Living AI Table Indexes
| Table | Index | Columns |
|-------|-------|---------|
| bot_states | ix_bot_states_guild_id | guild_id |
| bot_opinions | ix_bot_opinions_guild_id | guild_id |
| bot_opinions | ix_bot_opinions_topic | topic |
| user_relationships | ix_user_relationships_user_id | user_id |
| user_relationships | ix_user_relationships_guild_id | guild_id |
| user_communication_styles | ix_user_communication_styles_user_id | user_id |
| scheduled_events | ix_scheduled_events_user_id | user_id |
| scheduled_events | ix_scheduled_events_trigger_at | trigger_at |
| scheduled_events | ix_scheduled_events_status | status |
| fact_associations | ix_fact_associations_fact_id_1 | fact_id_1 |
| fact_associations | ix_fact_associations_fact_id_2 | fact_id_2 |
| mood_history | ix_mood_history_guild_id | guild_id |
| mood_history | ix_mood_history_recorded_at | recorded_at |
---
## Relationships
### Entity Relationship Diagram
```
┌──────────────────┐ ┌──────────────────┐
│ users │ │ guilds │
├──────────────────┤ ├──────────────────┤
│ id (PK) │◄────┐ │ id (PK) │◄────┐
│ discord_id │ │ │ discord_id │ │
│ discord_username │ │ │ name │ │
│ custom_name │ │ │ settings │ │
└──────────────────┘ │ └──────────────────┘ │
│ │ │ │
│ │ │ │
▼ │ ▼ │
┌──────────────────┐ │ ┌──────────────────┐ │
│ user_facts │ │ │ guild_members │ │
├──────────────────┤ │ ├──────────────────┤ │
│ id (PK) │ │ │ id (PK) │ │
│ user_id (FK) ────┼─────┘ │ guild_id (FK) ───┼─────┘
│ fact_type │ │ user_id (FK) ────┼─────┐
│ fact_content │ │ guild_nickname │ │
└──────────────────┘ └──────────────────┘ │
▲ │
│ │
│ │
┌────────┴─────────┐ ┌──────────────────┐ │
│fact_associations │ │ conversations │ │
├──────────────────┤ ├──────────────────┤ │
│ id (PK) │ │ id (PK) │◄────┤
│ fact_id_1 (FK) │ │ user_id (FK) ────┼─────┘
│ fact_id_2 (FK) │ │ guild_id │
│ association_type │ │ channel_id │
└──────────────────┘ └──────────────────┘
┌──────────────────┐
│ messages │
├──────────────────┤
│ id (PK) │
│ conversation_id │
│ user_id (FK) │
│ role │
│ content │
└──────────────────┘
```
### Living AI Relationships
```
┌──────────────────┐
│ users │
├──────────────────┤
│ id (PK) │◄─────────────────────────────────────┐
└──────────────────┘ │
│ │
│ │
┌────┴────────┬───────────────┬───────────────┐ │
│ │ │ │ │
▼ ▼ ▼ ▼ │
┌────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│user_ │ │user_comm │ │scheduled │ │mood_ │ │
│relation│ │styles │ │events │ │history │ │
│ships │ │ │ │ │ │ │ │
├────────┤ ├────────────┤ ├────────────┤ ├────────────┤ │
│user_id │ │user_id │ │user_id │ │trigger_ │ │
│guild_id│ │preferred_ │ │guild_id │ │user_id ────┼──┘
│score │ │length │ │event_type │ │valence │
│ │ │emoji │ │trigger_at │ │arousal │
└────────┘ └────────────┘ └────────────┘ └────────────┘
┌──────────────────┐ ┌──────────────────┐
│ bot_states │ │ bot_opinions │
├──────────────────┤ ├──────────────────┤
│ guild_id (unique)│ │ guild_id │
│ mood_valence │ │ topic │
│ mood_arousal │ │ sentiment │
│ total_messages │ │ interest_level │
│ total_facts │ │ discussion_count │
└──────────────────┘ └──────────────────┘
```
---
## Schema Initialization
### From Code
```python
from loyal_companion.services.database import db
# Initialize connection
await db.init()
# Create tables from schema.sql
await db.create_tables()
```
### From SQL
```bash
# Create database
createdb loyal_companion
# Run schema
psql -U postgres -d loyal_companion -f schema.sql
```
### Docker
The `docker-compose.yml` handles database setup automatically:
```bash
docker-compose up -d
```
---
## Migrations
The project uses **Alembic** for migrations, but currently relies on `schema.sql` for initial setup.
```bash
# Generate migration
alembic revision --autogenerate -m "description"
# Run migrations
alembic upgrade head
# Rollback
alembic downgrade -1
```
---
## Type Compatibility
### PortableJSON
For JSONB (PostgreSQL) and JSON (SQLite) compatibility:
```python
from loyal_companion.models.base import PortableJSON
class MyModel(Base):
settings = Column(PortableJSON, default={})
```
### ensure_utc()
Handles timezone-naive datetimes from SQLite:
```python
from loyal_companion.models.base import ensure_utc
# Safe for both PostgreSQL (already UTC) and SQLite (naive)
utc_time = ensure_utc(model.created_at)
```