fix: Make tests pass and update documentation
- Fix config.py to ignore extra environment variables (docker-compose compatibility) - Create PortableJSON type for SQLite/PostgreSQL compatibility in tests - Replace JSONB and ARRAY types with PortableJSON in models - Add ensure_utc() helper to handle timezone-naive datetimes from SQLite - Fix timezone issues in mood_service, relationship_service, and self_awareness_service - Fix duplicate code in test_providers.py - Update CLAUDE.md with comprehensive Living AI documentation - Add testing section with commands and setup details - All 112 tests now pass successfully
This commit is contained in:
@@ -23,7 +23,7 @@ GEMINI_API_KEY=xxx
|
|||||||
AI_MAX_TOKENS=1024
|
AI_MAX_TOKENS=1024
|
||||||
|
|
||||||
# AI creativity/randomness (0.0 = deterministic, 2.0 = very creative)
|
# AI creativity/randomness (0.0 = deterministic, 2.0 = very creative)
|
||||||
AI_TEMPERATURE=0.7
|
AI_TEMPERATURE=1
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Bot Identity & Personality
|
# Bot Identity & Personality
|
||||||
@@ -62,6 +62,8 @@ CONVERSATION_TIMEOUT_MINUTES=60
|
|||||||
|
|
||||||
# Password for PostgreSQL when using docker-compose
|
# Password for PostgreSQL when using docker-compose
|
||||||
POSTGRES_PASSWORD=daemon
|
POSTGRES_PASSWORD=daemon
|
||||||
|
POSTGRES_USER=daemon
|
||||||
|
POSTGRES_DB=daemon_boyfriend
|
||||||
|
|
||||||
# Echo SQL statements for debugging (true/false)
|
# Echo SQL statements for debugging (true/false)
|
||||||
DATABASE_ECHO=false
|
DATABASE_ECHO=false
|
||||||
|
|||||||
103
CLAUDE.md
103
CLAUDE.md
@@ -8,6 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Install in development mode (required for testing)
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
# Run the bot (requires .env with DISCORD_TOKEN and AI provider key)
|
# Run the bot (requires .env with DISCORD_TOKEN and AI provider key)
|
||||||
python -m daemon_boyfriend
|
python -m daemon_boyfriend
|
||||||
|
|
||||||
@@ -21,9 +24,33 @@ alembic upgrade head
|
|||||||
python -m py_compile src/daemon_boyfriend/**/*.py
|
python -m py_compile src/daemon_boyfriend/**/*.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dev dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
python -m pytest tests/ --cov=daemon_boyfriend --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
python -m pytest tests/test_models.py -v
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
python -m pytest tests/test_services.py::TestMoodService -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The test suite uses:
|
||||||
|
- `pytest` with `pytest-asyncio` for async test support
|
||||||
|
- SQLite in-memory database for testing (via `aiosqlite`)
|
||||||
|
- Mock fixtures for Discord objects and AI providers in `tests/conftest.py`
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This is a Discord bot that responds to @mentions with AI-generated responses (multi-provider support).
|
This is a Discord bot that responds to @mentions with AI-generated responses (multi-provider support). It features a "Living AI" system that gives the bot personality, mood, and relationship tracking.
|
||||||
|
|
||||||
### Provider Pattern
|
### Provider Pattern
|
||||||
The AI system uses a provider abstraction pattern:
|
The AI system uses a provider abstraction pattern:
|
||||||
@@ -44,6 +71,7 @@ Cogs are auto-loaded by `bot.py` from the `cogs/` directory.
|
|||||||
### Database & Memory System
|
### Database & Memory System
|
||||||
The bot uses PostgreSQL for persistent memory (optional, falls back to in-memory):
|
The bot uses PostgreSQL for persistent memory (optional, falls back to in-memory):
|
||||||
- `models/` - SQLAlchemy models (User, UserFact, Conversation, Message, Guild, GuildMember)
|
- `models/` - SQLAlchemy models (User, UserFact, Conversation, Message, Guild, GuildMember)
|
||||||
|
- `models/living_ai.py` - Living AI models (BotState, BotOpinion, UserRelationship, etc.)
|
||||||
- `services/database.py` - Connection pool and async session management
|
- `services/database.py` - Connection pool and async session management
|
||||||
- `services/user_service.py` - User CRUD, custom names, facts management
|
- `services/user_service.py` - User CRUD, custom names, facts management
|
||||||
- `services/persistent_conversation.py` - Database-backed conversation history
|
- `services/persistent_conversation.py` - Database-backed conversation history
|
||||||
@@ -55,6 +83,42 @@ Key features:
|
|||||||
- Persistent conversations: Chat history survives restarts
|
- Persistent conversations: Chat history survives restarts
|
||||||
- Conversation timeout: New conversation starts after 60 minutes of inactivity
|
- Conversation timeout: New conversation starts after 60 minutes of inactivity
|
||||||
|
|
||||||
|
### Living AI System
|
||||||
|
The bot implements a "Living AI" system with emotional depth and relationship tracking:
|
||||||
|
|
||||||
|
#### Services (`services/`)
|
||||||
|
- `mood_service.py` - Valence-arousal mood model with time decay
|
||||||
|
- `relationship_service.py` - Relationship scoring (stranger to close friend)
|
||||||
|
- `fact_extraction_service.py` - Autonomous fact learning from conversations
|
||||||
|
- `opinion_service.py` - Bot develops opinions on topics over time
|
||||||
|
- `self_awareness_service.py` - Bot statistics and self-reflection
|
||||||
|
- `communication_style_service.py` - Learns user communication preferences
|
||||||
|
- `proactive_service.py` - Scheduled events (birthdays, follow-ups)
|
||||||
|
- `association_service.py` - Cross-user memory associations
|
||||||
|
|
||||||
|
#### Models (`models/living_ai.py`)
|
||||||
|
- `BotState` - Global mood state and statistics per guild
|
||||||
|
- `BotOpinion` - Topic sentiments and preferences
|
||||||
|
- `UserRelationship` - Per-user relationship scores and metrics
|
||||||
|
- `UserCommunicationStyle` - Learned communication preferences
|
||||||
|
- `ScheduledEvent` - Birthdays, follow-ups, reminders
|
||||||
|
- `FactAssociation` - Cross-user memory links
|
||||||
|
- `MoodHistory` - Mood changes over time
|
||||||
|
|
||||||
|
#### Mood System
|
||||||
|
Uses a valence-arousal model:
|
||||||
|
- Valence: -1 (sad) to +1 (happy)
|
||||||
|
- Arousal: -1 (calm) to +1 (excited)
|
||||||
|
- Labels: excited, happy, calm, neutral, bored, annoyed, curious
|
||||||
|
- Time decay: Mood gradually returns to neutral
|
||||||
|
|
||||||
|
#### Relationship Levels
|
||||||
|
- Stranger (0-20): Polite, formal
|
||||||
|
- Acquaintance (21-40): Friendly but reserved
|
||||||
|
- Friend (41-60): Casual, warm
|
||||||
|
- Good Friend (61-80): Personal, references past talks
|
||||||
|
- Close Friend (81-100): Very casual, inside jokes
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
All config flows through `config.py` using pydantic-settings. The `settings` singleton is created at module load, so env vars must be set before importing.
|
All config flows through `config.py` using pydantic-settings. The `settings` singleton is created at module load, so env vars must be set before importing.
|
||||||
|
|
||||||
@@ -72,6 +136,8 @@ The bot can search the web for current information via SearXNG:
|
|||||||
- The bot responds only to @mentions via `on_message` listener
|
- The bot responds only to @mentions via `on_message` listener
|
||||||
- Web search uses AI to decide when to search, avoiding unnecessary API calls for general knowledge questions
|
- Web search uses AI to decide when to search, avoiding unnecessary API calls for general knowledge questions
|
||||||
- User context (custom name + known facts) is included in AI prompts for personalized responses
|
- User context (custom name + known facts) is included in AI prompts for personalized responses
|
||||||
|
- `PortableJSON` type in `models/base.py` allows models to work with both PostgreSQL (JSONB) and SQLite (JSON)
|
||||||
|
- `ensure_utc()` helper handles timezone-naive datetimes from SQLite
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -82,15 +148,44 @@ Optional:
|
|||||||
- `POSTGRES_PASSWORD` - Used by docker-compose for the PostgreSQL container
|
- `POSTGRES_PASSWORD` - Used by docker-compose for the PostgreSQL container
|
||||||
- `SEARXNG_URL` - SearXNG instance URL for web search capability
|
- `SEARXNG_URL` - SearXNG instance URL for web search capability
|
||||||
|
|
||||||
## Memory Commands
|
### Living AI Configuration
|
||||||
|
- `LIVING_AI_ENABLED` - Master switch for Living AI features (default: true)
|
||||||
|
- `MOOD_ENABLED` - Enable mood system (default: true)
|
||||||
|
- `RELATIONSHIP_ENABLED` - Enable relationship tracking (default: true)
|
||||||
|
- `FACT_EXTRACTION_ENABLED` - Enable autonomous fact extraction (default: true)
|
||||||
|
- `FACT_EXTRACTION_RATE` - Probability of extracting facts (default: 0.3)
|
||||||
|
- `PROACTIVE_ENABLED` - Enable proactive messages (default: true)
|
||||||
|
- `CROSS_USER_ENABLED` - Enable cross-user memory associations (default: false)
|
||||||
|
- `OPINION_FORMATION_ENABLED` - Enable bot opinion formation (default: true)
|
||||||
|
- `STYLE_LEARNING_ENABLED` - Enable communication style learning (default: true)
|
||||||
|
- `MOOD_DECAY_RATE` - How fast mood returns to neutral per hour (default: 0.1)
|
||||||
|
|
||||||
User commands:
|
### Command Toggles
|
||||||
|
- `COMMANDS_ENABLED` - Master switch for all commands (default: true)
|
||||||
|
- `CMD_RELATIONSHIP_ENABLED` - Enable `!relationship` command
|
||||||
|
- `CMD_MOOD_ENABLED` - Enable `!mood` command
|
||||||
|
- `CMD_BOTSTATS_ENABLED` - Enable `!botstats` command
|
||||||
|
- `CMD_OURHISTORY_ENABLED` - Enable `!ourhistory` command
|
||||||
|
- `CMD_BIRTHDAY_ENABLED` - Enable `!birthday` command
|
||||||
|
- `CMD_REMEMBER_ENABLED` - Enable `!remember` command
|
||||||
|
- `CMD_SETNAME_ENABLED` - Enable `!setname` command
|
||||||
|
- `CMD_WHATDOYOUKNOW_ENABLED` - Enable `!whatdoyouknow` command
|
||||||
|
- `CMD_FORGETME_ENABLED` - Enable `!forgetme` command
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### User commands
|
||||||
- `!setname <name>` - Set your preferred name
|
- `!setname <name>` - Set your preferred name
|
||||||
- `!clearname` - Reset to Discord display name
|
- `!clearname` - Reset to Discord display name
|
||||||
- `!remember <fact>` - Tell the bot something about you
|
- `!remember <fact>` - Tell the bot something about you
|
||||||
- `!whatdoyouknow` - See what the bot remembers about you
|
- `!whatdoyouknow` - See what the bot remembers about you
|
||||||
- `!forgetme` - Clear all facts about you
|
- `!forgetme` - Clear all facts about you
|
||||||
|
- `!relationship` - See your relationship level with the bot
|
||||||
|
- `!mood` - See the bot's current emotional state
|
||||||
|
- `!botstats` - Bot shares its self-awareness statistics
|
||||||
|
- `!ourhistory` - See your history with the bot
|
||||||
|
- `!birthday <date>` - Set your birthday for the bot to remember
|
||||||
|
|
||||||
Admin commands:
|
### Admin commands
|
||||||
- `!setusername @user <name>` - Set name for another user
|
- `!setusername @user <name>` - Set name for another user
|
||||||
- `!teachbot @user <fact>` - Add a fact about a user
|
- `!teachbot @user <fact>` - Add a fact about a user
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: daemon-boyfriend-postgres
|
container_name: daemon-boyfriend-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# optional
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: daemon
|
POSTGRES_USER: ${POSTGRES_USER:-daemon}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon}
|
||||||
POSTGRES_DB: daemon_boyfriend
|
POSTGRES_DB: ${POSTGRES_DB:-daemon_boyfriend}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
|
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
|
|||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Discord Configuration
|
# Discord Configuration
|
||||||
|
|||||||
@@ -2,9 +2,23 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import DateTime, MetaData
|
from sqlalchemy import JSON, DateTime, MetaData
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import TypeDecorator
|
||||||
|
|
||||||
|
|
||||||
|
class PortableJSON(TypeDecorator):
|
||||||
|
"""A JSON type that uses JSONB on PostgreSQL and JSON on other databases."""
|
||||||
|
|
||||||
|
impl = JSON
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def load_dialect_impl(self, dialect):
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return dialect.type_descriptor(JSONB())
|
||||||
|
return dialect.type_descriptor(JSON())
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
def utc_now() -> datetime:
|
||||||
@@ -12,6 +26,19 @@ def utc_now() -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_utc(dt: datetime | None) -> datetime | None:
|
||||||
|
"""Ensure a datetime is timezone-aware (UTC).
|
||||||
|
|
||||||
|
SQLite doesn't preserve timezone info, so this function adds UTC
|
||||||
|
timezone to naive datetimes returned from the database.
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
# Naming convention for constraints (helps with migrations)
|
# Naming convention for constraints (helps with migrations)
|
||||||
convention = {
|
convention = {
|
||||||
"ix": "ix_%(column_0_label)s",
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import ARRAY, BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .base import Base, utc_now
|
from .base import Base, PortableJSON, utc_now
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .user import User
|
from .user import User
|
||||||
@@ -51,7 +51,7 @@ class Message(Base):
|
|||||||
role: Mapped[str] = mapped_column(String(20)) # user, assistant, system
|
role: Mapped[str] = mapped_column(String(20)) # user, assistant, system
|
||||||
content: Mapped[str] = mapped_column(Text)
|
content: Mapped[str] = mapped_column(Text)
|
||||||
has_images: Mapped[bool] = mapped_column(Boolean, default=False)
|
has_images: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
image_urls: Mapped[list[str] | None] = mapped_column(ARRAY(Text), default=None)
|
image_urls: Mapped[list[str] | None] = mapped_column(PortableJSON, default=None)
|
||||||
token_count: Mapped[int | None] = mapped_column(Integer)
|
token_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -4,19 +4,16 @@ from datetime import datetime
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
ARRAY,
|
|
||||||
BigInteger,
|
BigInteger,
|
||||||
Boolean,
|
Boolean,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
String,
|
String,
|
||||||
Text,
|
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .base import Base, utc_now
|
from .base import Base, PortableJSON, utc_now
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .user import User
|
from .user import User
|
||||||
@@ -32,7 +29,7 @@ class Guild(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(255))
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
settings: Mapped[dict] = mapped_column(JSONB, default=dict)
|
settings: Mapped[dict] = mapped_column(PortableJSON, default=dict)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
members: Mapped[list["GuildMember"]] = relationship(
|
members: Mapped[list["GuildMember"]] = relationship(
|
||||||
@@ -49,7 +46,7 @@ class GuildMember(Base):
|
|||||||
guild_id: Mapped[int] = mapped_column(ForeignKey("guilds.id", ondelete="CASCADE"), index=True)
|
guild_id: Mapped[int] = mapped_column(ForeignKey("guilds.id", ondelete="CASCADE"), index=True)
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||||
guild_nickname: Mapped[str | None] = mapped_column(String(255))
|
guild_nickname: Mapped[str | None] = mapped_column(String(255))
|
||||||
roles: Mapped[list[str] | None] = mapped_column(ARRAY(Text), default=None)
|
roles: Mapped[list[str] | None] = mapped_column(PortableJSON, default=None)
|
||||||
joined_guild_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
joined_guild_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ from sqlalchemy import (
|
|||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .base import Base, utc_now
|
from .base import Base, PortableJSON, utc_now
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .user import User, UserFact
|
from .user import User, UserFact
|
||||||
@@ -42,7 +41,7 @@ class BotState(Base):
|
|||||||
first_activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
first_activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
|
|
||||||
# Bot preferences (evolved over time)
|
# Bot preferences (evolved over time)
|
||||||
preferences: Mapped[dict] = mapped_column(JSONB, default=dict)
|
preferences: Mapped[dict] = mapped_column(PortableJSON, default=dict)
|
||||||
|
|
||||||
|
|
||||||
class BotOpinion(Base):
|
class BotOpinion(Base):
|
||||||
@@ -88,7 +87,7 @@ class UserRelationship(Base):
|
|||||||
conversation_depth_avg: Mapped[float] = mapped_column(Float, default=0.0)
|
conversation_depth_avg: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
|
||||||
# Inside jokes / shared references
|
# Inside jokes / shared references
|
||||||
shared_references: Mapped[dict] = mapped_column(JSONB, default=dict)
|
shared_references: Mapped[dict] = mapped_column(PortableJSON, default=dict)
|
||||||
|
|
||||||
first_interaction_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
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)
|
last_interaction_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
|
||||||
@@ -117,7 +116,7 @@ class UserCommunicationStyle(Base):
|
|||||||
detail_preference: Mapped[float] = mapped_column(Float, default=0.5) # 0=concise, 1=detailed
|
detail_preference: Mapped[float] = mapped_column(Float, default=0.5) # 0=concise, 1=detailed
|
||||||
|
|
||||||
# Engagement signals used to learn preferences
|
# Engagement signals used to learn preferences
|
||||||
engagement_signals: Mapped[dict] = mapped_column(JSONB, default=dict)
|
engagement_signals: Mapped[dict] = mapped_column(PortableJSON, default=dict)
|
||||||
|
|
||||||
samples_collected: Mapped[int] = mapped_column(default=0)
|
samples_collected: Mapped[int] = mapped_column(default=0)
|
||||||
confidence: Mapped[float] = mapped_column(Float, default=0.0) # 0-1
|
confidence: Mapped[float] = mapped_column(Float, default=0.0) # 0-1
|
||||||
@@ -140,7 +139,7 @@ class ScheduledEvent(Base):
|
|||||||
trigger_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
trigger_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
title: Mapped[str] = mapped_column(String(255))
|
title: Mapped[str] = mapped_column(String(255))
|
||||||
context: Mapped[dict] = mapped_column(JSONB, default=dict)
|
context: Mapped[dict] = mapped_column(PortableJSON, default=dict)
|
||||||
|
|
||||||
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_recurring: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
recurrence_rule: Mapped[str | None] = mapped_column(String(100))
|
recurrence_rule: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from daemon_boyfriend.config import settings
|
from daemon_boyfriend.config import settings
|
||||||
from daemon_boyfriend.models import BotState, MoodHistory
|
from daemon_boyfriend.models import BotState, MoodHistory
|
||||||
|
from daemon_boyfriend.models.base import ensure_utc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ class MoodService:
|
|||||||
|
|
||||||
# Apply time decay toward neutral
|
# Apply time decay toward neutral
|
||||||
hours_since_update = (
|
hours_since_update = (
|
||||||
datetime.now(timezone.utc) - bot_state.mood_updated_at
|
datetime.now(timezone.utc) - ensure_utc(bot_state.mood_updated_at)
|
||||||
).total_seconds() / 3600
|
).total_seconds() / 3600
|
||||||
decay_factor = max(0, 1 - (settings.mood_decay_rate * hours_since_update))
|
decay_factor = max(0, 1 - (settings.mood_decay_rate * hours_since_update))
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ class MoodService:
|
|||||||
async def get_stats(self, guild_id: int | None = None) -> dict:
|
async def get_stats(self, guild_id: int | None = None) -> dict:
|
||||||
"""Get bot statistics."""
|
"""Get bot statistics."""
|
||||||
bot_state = await self.get_or_create_bot_state(guild_id)
|
bot_state = await self.get_or_create_bot_state(guild_id)
|
||||||
age_delta = datetime.now(timezone.utc) - bot_state.first_activated_at
|
age_delta = datetime.now(timezone.utc) - ensure_utc(bot_state.first_activated_at)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"age_days": age_delta.days,
|
"age_days": age_delta.days,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from daemon_boyfriend.models import User, UserRelationship
|
from daemon_boyfriend.models import User, UserRelationship
|
||||||
|
from daemon_boyfriend.models.base import ensure_utc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -211,7 +212,7 @@ class RelationshipService:
|
|||||||
level = self.get_level(rel.relationship_score)
|
level = self.get_level(rel.relationship_score)
|
||||||
|
|
||||||
# Calculate time since first interaction
|
# Calculate time since first interaction
|
||||||
time_known = datetime.now(timezone.utc) - rel.first_interaction_at
|
time_known = datetime.now(timezone.utc) - ensure_utc(rel.first_interaction_at)
|
||||||
days_known = time_known.days
|
days_known = time_known.days
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from daemon_boyfriend.models import (
|
|||||||
UserFact,
|
UserFact,
|
||||||
UserRelationship,
|
UserRelationship,
|
||||||
)
|
)
|
||||||
|
from daemon_boyfriend.models.base import ensure_utc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ class SelfAwarenessService:
|
|||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
|
|
||||||
# Calculate age
|
# Calculate age
|
||||||
age_delta = datetime.now(timezone.utc) - bot_state.first_activated_at
|
age_delta = datetime.now(timezone.utc) - ensure_utc(bot_state.first_activated_at)
|
||||||
|
|
||||||
# Count users (from database)
|
# Count users (from database)
|
||||||
user_count = await self._count_users()
|
user_count = await self._count_users()
|
||||||
@@ -76,7 +77,7 @@ class SelfAwarenessService:
|
|||||||
facts_count = facts_result.scalar() or 0
|
facts_count = facts_result.scalar() or 0
|
||||||
|
|
||||||
if rel:
|
if rel:
|
||||||
days_known = (datetime.now(timezone.utc) - rel.first_interaction_at).days
|
days_known = (datetime.now(timezone.utc) - ensure_utc(rel.first_interaction_at)).days
|
||||||
return {
|
return {
|
||||||
"first_met": rel.first_interaction_at,
|
"first_met": rel.first_interaction_at,
|
||||||
"days_known": days_known,
|
"days_known": days_known,
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from daemon_boyfriend.services.providers.anthropic import AnthropicProvider
|
||||||
from daemon_boyfriend.services.providers.base import (
|
from daemon_boyfriend.services.providers.base import (
|
||||||
AIProvider,
|
AIProvider,
|
||||||
AIResponse,
|
AIResponse,
|
||||||
Message,
|
|
||||||
ImageAttachment,
|
ImageAttachment,
|
||||||
|
Message,
|
||||||
)
|
)
|
||||||
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.gemini import GeminiProvider
|
||||||
|
from daemon_boyfriend.services.providers.openai import OpenAIProvider
|
||||||
from daemon_boyfriend.services.providers.openrouter import OpenRouterProvider
|
from daemon_boyfriend.services.providers.openrouter import OpenRouterProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -144,12 +145,6 @@ class TestAnthropicProvider:
|
|||||||
with patch(
|
with patch(
|
||||||
"daemon_boyfriend.services.providers.anthropic.anthropic.AsyncAnthropic"
|
"daemon_boyfriend.services.providers.anthropic.anthropic.AsyncAnthropic"
|
||||||
) as mock_class:
|
) 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
|
mock_class.return_value = mock_anthropic_client
|
||||||
provider = AnthropicProvider(api_key="test_key", model="claude-sonnet-4-20250514")
|
provider = AnthropicProvider(api_key="test_key", model="claude-sonnet-4-20250514")
|
||||||
provider.client = mock_anthropic_client
|
provider.client = mock_anthropic_client
|
||||||
|
|||||||
Reference in New Issue
Block a user