feat(memory): add database schema and storage layer (Issue #88)
Add SQLAlchemy models for the Agent Memory System: - WorkingMemory: Key-value storage with TTL for active sessions - Episode: Experiential memories from task executions - Fact: Semantic knowledge triples with confidence scores - Procedure: Learned skills and procedures with success tracking - MemoryConsolidationLog: Tracks consolidation jobs between memory tiers Create enums for memory system: - ScopeType: global, project, agent_type, agent_instance, session - EpisodeOutcome: success, failure, partial - ConsolidationType: working_to_episodic, episodic_to_semantic, etc. - ConsolidationStatus: pending, running, completed, failed Add Alembic migration (0005) for all memory tables with: - Foreign key relationships to projects, agent_instances, agent_types - Comprehensive indexes for query patterns - Unique constraints for key lookups and triple uniqueness - Vector embedding column placeholders (Text fallback until pgvector enabled) Fix timezone-naive datetime.now() in types.py TaskState (review feedback) Includes 30 unit tests for models and enums. Closes #88 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2
backend/tests/unit/models/__init__.py
Normal file
2
backend/tests/unit/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# tests/unit/models/__init__.py
|
||||
"""Unit tests for database models."""
|
||||
2
backend/tests/unit/models/memory/__init__.py
Normal file
2
backend/tests/unit/models/memory/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# tests/unit/models/memory/__init__.py
|
||||
"""Unit tests for memory database models."""
|
||||
71
backend/tests/unit/models/memory/test_enums.py
Normal file
71
backend/tests/unit/models/memory/test_enums.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# tests/unit/models/memory/test_enums.py
|
||||
"""Unit tests for memory model enums."""
|
||||
|
||||
from app.models.memory.enums import (
|
||||
ConsolidationStatus,
|
||||
ConsolidationType,
|
||||
EpisodeOutcome,
|
||||
ScopeType,
|
||||
)
|
||||
|
||||
|
||||
class TestScopeType:
|
||||
"""Tests for ScopeType enum."""
|
||||
|
||||
def test_all_values_exist(self) -> None:
|
||||
"""Test all expected scope types exist."""
|
||||
assert ScopeType.GLOBAL.value == "global"
|
||||
assert ScopeType.PROJECT.value == "project"
|
||||
assert ScopeType.AGENT_TYPE.value == "agent_type"
|
||||
assert ScopeType.AGENT_INSTANCE.value == "agent_instance"
|
||||
assert ScopeType.SESSION.value == "session"
|
||||
|
||||
def test_scope_count(self) -> None:
|
||||
"""Test we have exactly 5 scope types."""
|
||||
assert len(ScopeType) == 5
|
||||
|
||||
|
||||
class TestEpisodeOutcome:
|
||||
"""Tests for EpisodeOutcome enum."""
|
||||
|
||||
def test_all_values_exist(self) -> None:
|
||||
"""Test all expected outcome values exist."""
|
||||
assert EpisodeOutcome.SUCCESS.value == "success"
|
||||
assert EpisodeOutcome.FAILURE.value == "failure"
|
||||
assert EpisodeOutcome.PARTIAL.value == "partial"
|
||||
|
||||
def test_outcome_count(self) -> None:
|
||||
"""Test we have exactly 3 outcome types."""
|
||||
assert len(EpisodeOutcome) == 3
|
||||
|
||||
|
||||
class TestConsolidationType:
|
||||
"""Tests for ConsolidationType enum."""
|
||||
|
||||
def test_all_values_exist(self) -> None:
|
||||
"""Test all expected consolidation types exist."""
|
||||
assert ConsolidationType.WORKING_TO_EPISODIC.value == "working_to_episodic"
|
||||
assert ConsolidationType.EPISODIC_TO_SEMANTIC.value == "episodic_to_semantic"
|
||||
assert (
|
||||
ConsolidationType.EPISODIC_TO_PROCEDURAL.value == "episodic_to_procedural"
|
||||
)
|
||||
assert ConsolidationType.PRUNING.value == "pruning"
|
||||
|
||||
def test_consolidation_count(self) -> None:
|
||||
"""Test we have exactly 4 consolidation types."""
|
||||
assert len(ConsolidationType) == 4
|
||||
|
||||
|
||||
class TestConsolidationStatus:
|
||||
"""Tests for ConsolidationStatus enum."""
|
||||
|
||||
def test_all_values_exist(self) -> None:
|
||||
"""Test all expected status values exist."""
|
||||
assert ConsolidationStatus.PENDING.value == "pending"
|
||||
assert ConsolidationStatus.RUNNING.value == "running"
|
||||
assert ConsolidationStatus.COMPLETED.value == "completed"
|
||||
assert ConsolidationStatus.FAILED.value == "failed"
|
||||
|
||||
def test_status_count(self) -> None:
|
||||
"""Test we have exactly 4 status types."""
|
||||
assert len(ConsolidationStatus) == 4
|
||||
249
backend/tests/unit/models/memory/test_models.py
Normal file
249
backend/tests/unit/models/memory/test_models.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# tests/unit/models/memory/test_models.py
|
||||
"""Unit tests for memory database models."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.memory import (
|
||||
ConsolidationStatus,
|
||||
ConsolidationType,
|
||||
Episode,
|
||||
EpisodeOutcome,
|
||||
Fact,
|
||||
MemoryConsolidationLog,
|
||||
Procedure,
|
||||
ScopeType,
|
||||
WorkingMemory,
|
||||
)
|
||||
|
||||
|
||||
class TestWorkingMemoryModel:
|
||||
"""Tests for WorkingMemory model."""
|
||||
|
||||
def test_tablename(self) -> None:
|
||||
"""Test table name is correct."""
|
||||
assert WorkingMemory.__tablename__ == "working_memory"
|
||||
|
||||
def test_has_required_columns(self) -> None:
|
||||
"""Test all required columns exist."""
|
||||
columns = WorkingMemory.__table__.columns
|
||||
assert "id" in columns
|
||||
assert "scope_type" in columns
|
||||
assert "scope_id" in columns
|
||||
assert "key" in columns
|
||||
assert "value" in columns
|
||||
assert "expires_at" in columns
|
||||
assert "created_at" in columns
|
||||
assert "updated_at" in columns
|
||||
|
||||
def test_has_unique_constraint(self) -> None:
|
||||
"""Test unique constraint on scope+key."""
|
||||
indexes = {idx.name: idx for idx in WorkingMemory.__table__.indexes}
|
||||
assert "ix_working_memory_scope_key" in indexes
|
||||
assert indexes["ix_working_memory_scope_key"].unique
|
||||
|
||||
|
||||
class TestEpisodeModel:
|
||||
"""Tests for Episode model."""
|
||||
|
||||
def test_tablename(self) -> None:
|
||||
"""Test table name is correct."""
|
||||
assert Episode.__tablename__ == "episodes"
|
||||
|
||||
def test_has_required_columns(self) -> None:
|
||||
"""Test all required columns exist."""
|
||||
columns = Episode.__table__.columns
|
||||
required = [
|
||||
"id",
|
||||
"project_id",
|
||||
"agent_instance_id",
|
||||
"agent_type_id",
|
||||
"session_id",
|
||||
"task_type",
|
||||
"task_description",
|
||||
"actions",
|
||||
"context_summary",
|
||||
"outcome",
|
||||
"outcome_details",
|
||||
"duration_seconds",
|
||||
"tokens_used",
|
||||
"lessons_learned",
|
||||
"importance_score",
|
||||
"embedding",
|
||||
"occurred_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
for col in required:
|
||||
assert col in columns, f"Missing column: {col}"
|
||||
|
||||
def test_has_foreign_keys(self) -> None:
|
||||
"""Test foreign key relationships exist."""
|
||||
columns = Episode.__table__.columns
|
||||
assert columns["project_id"].foreign_keys
|
||||
assert columns["agent_instance_id"].foreign_keys
|
||||
assert columns["agent_type_id"].foreign_keys
|
||||
|
||||
def test_has_relationships(self) -> None:
|
||||
"""Test ORM relationships exist."""
|
||||
mapper = Episode.__mapper__
|
||||
assert "project" in mapper.relationships
|
||||
assert "agent_instance" in mapper.relationships
|
||||
assert "agent_type" in mapper.relationships
|
||||
|
||||
|
||||
class TestFactModel:
|
||||
"""Tests for Fact model."""
|
||||
|
||||
def test_tablename(self) -> None:
|
||||
"""Test table name is correct."""
|
||||
assert Fact.__tablename__ == "facts"
|
||||
|
||||
def test_has_required_columns(self) -> None:
|
||||
"""Test all required columns exist."""
|
||||
columns = Fact.__table__.columns
|
||||
required = [
|
||||
"id",
|
||||
"project_id",
|
||||
"subject",
|
||||
"predicate",
|
||||
"object",
|
||||
"confidence",
|
||||
"source_episode_ids",
|
||||
"first_learned",
|
||||
"last_reinforced",
|
||||
"reinforcement_count",
|
||||
"embedding",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
for col in required:
|
||||
assert col in columns, f"Missing column: {col}"
|
||||
|
||||
def test_project_id_nullable(self) -> None:
|
||||
"""Test project_id is nullable for global facts."""
|
||||
columns = Fact.__table__.columns
|
||||
assert columns["project_id"].nullable
|
||||
|
||||
|
||||
class TestProcedureModel:
|
||||
"""Tests for Procedure model."""
|
||||
|
||||
def test_tablename(self) -> None:
|
||||
"""Test table name is correct."""
|
||||
assert Procedure.__tablename__ == "procedures"
|
||||
|
||||
def test_has_required_columns(self) -> None:
|
||||
"""Test all required columns exist."""
|
||||
columns = Procedure.__table__.columns
|
||||
required = [
|
||||
"id",
|
||||
"project_id",
|
||||
"agent_type_id",
|
||||
"name",
|
||||
"trigger_pattern",
|
||||
"steps",
|
||||
"success_count",
|
||||
"failure_count",
|
||||
"last_used",
|
||||
"embedding",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
for col in required:
|
||||
assert col in columns, f"Missing column: {col}"
|
||||
|
||||
def test_success_rate_property(self) -> None:
|
||||
"""Test success_rate calculated property."""
|
||||
proc = Procedure()
|
||||
proc.success_count = 8
|
||||
proc.failure_count = 2
|
||||
assert proc.success_rate == 0.8
|
||||
|
||||
def test_success_rate_zero_total(self) -> None:
|
||||
"""Test success_rate with zero total uses."""
|
||||
proc = Procedure()
|
||||
proc.success_count = 0
|
||||
proc.failure_count = 0
|
||||
assert proc.success_rate == 0.0
|
||||
|
||||
def test_total_uses_property(self) -> None:
|
||||
"""Test total_uses calculated property."""
|
||||
proc = Procedure()
|
||||
proc.success_count = 5
|
||||
proc.failure_count = 3
|
||||
assert proc.total_uses == 8
|
||||
|
||||
|
||||
class TestMemoryConsolidationLogModel:
|
||||
"""Tests for MemoryConsolidationLog model."""
|
||||
|
||||
def test_tablename(self) -> None:
|
||||
"""Test table name is correct."""
|
||||
assert MemoryConsolidationLog.__tablename__ == "memory_consolidation_log"
|
||||
|
||||
def test_has_required_columns(self) -> None:
|
||||
"""Test all required columns exist."""
|
||||
columns = MemoryConsolidationLog.__table__.columns
|
||||
required = [
|
||||
"id",
|
||||
"consolidation_type",
|
||||
"source_count",
|
||||
"result_count",
|
||||
"started_at",
|
||||
"completed_at",
|
||||
"status",
|
||||
"error",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
for col in required:
|
||||
assert col in columns, f"Missing column: {col}"
|
||||
|
||||
def test_duration_seconds_property_completed(self) -> None:
|
||||
"""Test duration_seconds with completed job."""
|
||||
log = MemoryConsolidationLog()
|
||||
log.started_at = datetime.now(UTC)
|
||||
log.completed_at = log.started_at + timedelta(seconds=10)
|
||||
assert log.duration_seconds == pytest.approx(10.0)
|
||||
|
||||
def test_duration_seconds_property_incomplete(self) -> None:
|
||||
"""Test duration_seconds with incomplete job."""
|
||||
log = MemoryConsolidationLog()
|
||||
log.started_at = datetime.now(UTC)
|
||||
log.completed_at = None
|
||||
assert log.duration_seconds is None
|
||||
|
||||
def test_default_status(self) -> None:
|
||||
"""Test default status is PENDING."""
|
||||
columns = MemoryConsolidationLog.__table__.columns
|
||||
assert columns["status"].default.arg == ConsolidationStatus.PENDING
|
||||
|
||||
|
||||
class TestModelExports:
|
||||
"""Tests for model package exports."""
|
||||
|
||||
def test_all_models_exported(self) -> None:
|
||||
"""Test all models are exported from package."""
|
||||
from app.models.memory import (
|
||||
Episode,
|
||||
Fact,
|
||||
MemoryConsolidationLog,
|
||||
Procedure,
|
||||
WorkingMemory,
|
||||
)
|
||||
|
||||
# Verify these are the actual classes
|
||||
assert Episode.__tablename__ == "episodes"
|
||||
assert Fact.__tablename__ == "facts"
|
||||
assert Procedure.__tablename__ == "procedures"
|
||||
assert WorkingMemory.__tablename__ == "working_memory"
|
||||
assert MemoryConsolidationLog.__tablename__ == "memory_consolidation_log"
|
||||
|
||||
def test_enums_exported(self) -> None:
|
||||
"""Test all enums are exported."""
|
||||
assert ScopeType.GLOBAL.value == "global"
|
||||
assert EpisodeOutcome.SUCCESS.value == "success"
|
||||
assert ConsolidationType.WORKING_TO_EPISODIC.value == "working_to_episodic"
|
||||
assert ConsolidationStatus.PENDING.value == "pending"
|
||||
Reference in New Issue
Block a user