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

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Discord Configuration

View File

@@ -2,9 +2,23 @@
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.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:
@@ -12,6 +26,19 @@ def utc_now() -> datetime:
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)
convention = {
"ix": "ix_%(column_0_label)s",

View File

@@ -3,10 +3,10 @@
from datetime import datetime
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 .base import Base, utc_now
from .base import Base, PortableJSON, utc_now
if TYPE_CHECKING:
from .user import User
@@ -51,7 +51,7 @@ class Message(Base):
role: Mapped[str] = mapped_column(String(20)) # user, assistant, system
content: Mapped[str] = mapped_column(Text)
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)
# Relationships

View File

@@ -4,19 +4,16 @@ from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
ARRAY,
BigInteger,
Boolean,
DateTime,
ForeignKey,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, utc_now
from .base import Base, PortableJSON, utc_now
if TYPE_CHECKING:
from .user import User
@@ -32,7 +29,7 @@ class Guild(Base):
name: Mapped[str] = mapped_column(String(255))
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
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
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)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
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)
# Relationships

View File

@@ -13,10 +13,9 @@ from sqlalchemy import (
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, utc_now
from .base import Base, PortableJSON, utc_now
if TYPE_CHECKING:
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)
# Bot preferences (evolved over time)
preferences: Mapped[dict] = mapped_column(JSONB, default=dict)
preferences: Mapped[dict] = mapped_column(PortableJSON, default=dict)
class BotOpinion(Base):
@@ -88,7 +87,7 @@ class UserRelationship(Base):
conversation_depth_avg: Mapped[float] = mapped_column(Float, default=0.0)
# 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)
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
# 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)
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)
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)
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.models import BotState, MoodHistory
from daemon_boyfriend.models.base import ensure_utc
logger = logging.getLogger(__name__)
@@ -61,7 +62,7 @@ class MoodService:
# Apply time decay toward neutral
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
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:
"""Get bot statistics."""
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 {
"age_days": age_delta.days,

View File

@@ -8,6 +8,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from daemon_boyfriend.models import User, UserRelationship
from daemon_boyfriend.models.base import ensure_utc
logger = logging.getLogger(__name__)
@@ -211,7 +212,7 @@ class RelationshipService:
level = self.get_level(rel.relationship_score)
# 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
return {

View File

@@ -13,6 +13,7 @@ from daemon_boyfriend.models import (
UserFact,
UserRelationship,
)
from daemon_boyfriend.models.base import ensure_utc
logger = logging.getLogger(__name__)
@@ -36,7 +37,7 @@ class SelfAwarenessService:
await self._session.flush()
# 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)
user_count = await self._count_users()
@@ -76,7 +77,7 @@ class SelfAwarenessService:
facts_count = facts_result.scalar() or 0
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 {
"first_met": rel.first_interaction_at,
"days_known": days_known,