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:
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Discord Configuration
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user