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:
2026-01-05 01:37:58 +01:00
parent 085a748929
commit c9d8c0835c
14 changed files with 1383 additions and 7 deletions

View File

@@ -0,0 +1,2 @@
# tests/unit/models/memory/__init__.py
"""Unit tests for memory database models."""

View 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

View 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"