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:
2026-01-13 14:59:46 +00:00
parent bfd42586df
commit ff394c9250
13 changed files with 186 additions and 64 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -1,33 +1,36 @@
services: services:
daemon-boyfriend: daemon-boyfriend:
build: . build: .
container_name: daemon-boyfriend container_name: daemon-boyfriend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend - DATABASE_URL=postgresql+asyncpg://daemon:${POSTGRES_PASSWORD:-daemon}@postgres:5432/daemon_boyfriend
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: daemon-boyfriend-postgres container_name: daemon-boyfriend-postgres
restart: unless-stopped restart: unless-stopped
environment: # optional
POSTGRES_USER: daemon ports:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon} - "5433:5432"
POSTGRES_DB: daemon_boyfriend environment:
volumes: POSTGRES_USER: ${POSTGRES_USER:-daemon}
- postgres_data:/var/lib/postgresql/data POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-daemon}
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro POSTGRES_DB: ${POSTGRES_DB:-daemon_boyfriend}
healthcheck: volumes:
test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"] - postgres_data:/var/lib/postgresql/data
interval: 10s - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
timeout: 5s healthcheck:
retries: 5 test: ["CMD-SHELL", "pg_isready -U daemon -d daemon_boyfriend"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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