activity service recent activity

This commit is contained in:
phernandez
2024-12-30 19:33:42 -06:00
parent 12a21d81b6
commit 7a06b87bd1
8 changed files with 605 additions and 3 deletions
+31 -1
View File
@@ -1,5 +1,6 @@
"""Base repository implementation."""
from datetime import datetime
from typing import Type, Optional, Any, Sequence, TypeVar, List
from loguru import logger
@@ -148,6 +149,35 @@ class Repository[T: Base]:
logger.debug(f"No {self.Model.__name__} found")
return entity
async def find_modified_since(self, since: datetime) -> Sequence[T]:
"""Find all records modified since the given timestamp.
This method assumes the model has an updated_at column. Override
in subclasses if a different column should be used.
Args:
since: Datetime to search from
Returns:
Sequence of records modified since the timestamp
"""
logger.debug(f"Finding {self.Model.__name__} modified since: {since}")
if not hasattr(self.Model, 'updated_at'):
raise AttributeError(f"{self.Model.__name__} does not have updated_at column")
query = (
select(self.Model)
.filter(self.Model.updated_at >= since)
.options(*self.get_load_options())
)
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(query)
items = result.scalars().all()
logger.debug(f"Found {len(items)} modified {self.Model.__name__} records")
return items
async def create(self, data: dict) -> T:
"""Create a new record from a model instance."""
logger.debug(f"Creating {self.Model.__name__} from entity_data: {data}")
@@ -260,4 +290,4 @@ class Repository[T: Base]:
def get_load_options(self) -> List[LoaderOption]:
"""Get list of loader options for eager loading relationships.
Override in subclasses to specify what to load."""
return []
return []
+83
View File
@@ -0,0 +1,83 @@
from datetime import datetime, timedelta
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field
class TimeFrame:
"""Represents a time period for querying activity."""
def __init__(self, timeframe_str: str):
"""Parse timeframe string (e.g., '1d', '2w', '1m')"""
if not timeframe_str or len(timeframe_str) < 2:
raise ValueError("Invalid timeframe format")
try:
self.value = int(timeframe_str[:-1])
self.unit = timeframe_str[-1]
except ValueError:
raise ValueError("Invalid timeframe format")
if self.unit not in ['h', 'd', 'w', 'm']:
raise ValueError("Invalid timeframe unit")
if self.value < 1:
raise ValueError("Timeframe value must be positive")
@property
def to_timedelta(self) -> timedelta:
"""Convert to Python timedelta."""
if self.unit == 'h':
return timedelta(hours=self.value)
elif self.unit == 'd':
return timedelta(days=self.value)
elif self.unit == 'w':
return timedelta(weeks=self.value)
elif self.unit == 'm':
# Approximate month as 30 days
return timedelta(days=self.value * 30)
else:
raise ValueError(f"Invalid unit: {self.unit}")
class ActivityType(str, Enum):
"""Types of activities that can be tracked."""
DOCUMENT = "document"
ENTITY = "entity"
RELATION = "relation"
class ChangeType(str, Enum):
"""Types of changes that can occur."""
CREATED = "created"
UPDATED = "updated"
DELETED = "deleted"
class ActivityChange(BaseModel):
"""Represents a single change in the system."""
activity_type: ActivityType
change_type: ChangeType
timestamp: datetime
path_id: str
summary: str
content: Optional[str] = None
class ActivitySummary(BaseModel):
"""Summary statistics about recent activity."""
document_changes: int = Field(default=0, description="Number of document changes")
entity_changes: int = Field(default=0, description="Number of entity changes")
relation_changes: int = Field(default=0, description="Number of relation changes")
most_active_paths: List[str] = Field(
default_factory=list,
description="List of most frequently changed paths"
)
class RecentActivity(BaseModel):
"""Complete activity report."""
timeframe: str
changes: List[ActivityChange] = Field(default_factory=list)
summary: ActivitySummary
@@ -0,0 +1,181 @@
from datetime import datetime
from typing import List, Optional, Sequence
from . import EntityService, DocumentService, RelationService
from ..models import Document
from ..schemas.activity import (
ActivityChange,
ActivitySummary,
ActivityType,
ChangeType,
RecentActivity,
TimeFrame,
)
class ActivityService:
"""Service for tracking and querying activity across the knowledge base."""
def __init__(
self,
entity_service: EntityService,
document_service: DocumentService,
relation_service: RelationService,
):
"""Initialize with required services."""
self.entity_service = entity_service
self.document_service = document_service
self.relation_service = relation_service
async def get_recent_activity(
self,
timeframe: str = "1d",
activity_types: Optional[List[str]] = None,
include_content: bool = True
) -> RecentActivity:
"""Get all recent activity in the knowledge base.
Args:
timeframe: Time window to look back (1h, 1d, 1w, 1m)
activity_types: Optional list of types to include
include_content: Whether to include full content
Returns:
RecentActivity object containing changes and summary
"""
# Parse timeframe and get cutoff date
tf = TimeFrame(timeframe)
since = datetime.utcnow() - tf.to_timedelta
# Get changes based on requested types
changes = []
types_to_fetch = (
[ActivityType(t) for t in activity_types]
if activity_types
else list(ActivityType)
)
for activity_type in types_to_fetch:
if activity_type == ActivityType.ENTITY:
changes.extend(await self._get_entity_changes(since))
elif activity_type == ActivityType.DOCUMENT:
changes.extend(await self._get_document_changes(since))
elif activity_type == ActivityType.RELATION:
changes.extend(await self._get_relation_changes(since))
# Sort all changes by timestamp
changes.sort(key=lambda x: x.timestamp, reverse=True)
# Remove content if not requested
if not include_content:
for change in changes:
change.content = None
# Generate summary
summary = ActivitySummary(
document_changes=len([c for c in changes if c.activity_type == ActivityType.DOCUMENT]),
entity_changes=len([c for c in changes if c.activity_type == ActivityType.ENTITY]),
relation_changes=len([c for c in changes if c.activity_type == ActivityType.RELATION]),
most_active_paths=self._get_most_active_paths(changes)
)
return RecentActivity(
timeframe=timeframe,
changes=changes,
summary=summary
)
async def _get_entity_changes(self, since: datetime) -> List[ActivityChange]:
"""Get recent entity changes."""
# Query entities updated since the cutoff
entities = await self.entity_service.get_modified_since(since)
changes = []
for entity in entities:
change_type = (
ChangeType.CREATED
if entity.created_at >= since
else ChangeType.UPDATED
)
changes.append(
ActivityChange(
activity_type=ActivityType.ENTITY,
change_type=change_type,
timestamp=entity.updated_at,
path_id=entity.path_id,
summary=f"{change_type.value.title()} entity: {entity.name}",
content=entity.description
)
)
return changes
async def _get_document_changes(self, since: datetime) -> List[ActivityChange]:
"""Get recent document changes."""
# Query documents updated since the cutoff
documents: Sequence[Document] = await self.document_service.get_modified_since(since)
changes = []
for doc in documents:
change_type = (
ChangeType.CREATED
if doc.created_at >= since
else ChangeType.UPDATED
)
changes.append(
ActivityChange(
activity_type=ActivityType.DOCUMENT,
change_type=change_type,
timestamp=doc.updated_at,
path_id=doc.path_id,
summary=f"{change_type.value.title()} document: {doc.path_id}",
#content=doc.content[:500] if doc.content else None # First 500 chars
)
)
return changes
async def _get_relation_changes(self, since: datetime) -> List[ActivityChange]:
"""Get recent relation changes."""
# Query relations updated since the cutoff
relations = await self.relation_service.get_modified_since(since)
changes = []
for relation in relations:
change_type = (
ChangeType.CREATED
if relation.created_at >= since
else ChangeType.UPDATED
)
changes.append(
ActivityChange(
activity_type=ActivityType.RELATION,
change_type=change_type,
timestamp=relation.updated_at,
path_id=f"{relation.from_id}->{relation.to_id}",
summary=(
f"{change_type.value.title()} relation: "
f"{relation.from_id} {relation.relation_type} {relation.to_id}"
),
content=relation.context
)
)
return changes
def _get_most_active_paths(self, changes: List[ActivityChange], limit: int = 5) -> List[str]:
"""Get the most frequently changed paths."""
path_counts = {}
for change in changes:
path_counts[change.path_id] = path_counts.get(change.path_id, 0) + 1
# Sort by count descending and take top paths
sorted_paths = sorted(
path_counts.items(),
key=lambda x: (-x[1], x[0]) # Sort by count desc, then path asc
)
return [path for path, _ in sorted_paths[:limit]]
+14 -1
View File
@@ -1,10 +1,12 @@
"""Base service class."""
from datetime import datetime
from typing import TypeVar, Generic, List, Sequence
from basic_memory.models import Base
from basic_memory.repository.repository import Repository
T = TypeVar("T", bound=Repository)
T = TypeVar("T", bound=Base)
class BaseService(Generic[T]):
@@ -21,3 +23,14 @@ class BaseService(Generic[T]):
async def add_all(self, models: List[T]) -> Sequence[T]:
"""Add a List of models to repository."""
return await self.repository.add_all(models)
async def get_modified_since(self, since: datetime) -> Sequence[T]:
"""Get all items modified since the given timestamp.
Args:
since: Datetime to search from
Returns:
Sequence of items modified since the timestamp
"""
return await self.repository.find_modified_since(since)