add get_recent_activity endpoint

This commit is contained in:
phernandez
2024-12-30 20:32:55 -06:00
parent 7a06b87bd1
commit 1507875785
10 changed files with 445 additions and 99 deletions
+2
View File
@@ -9,6 +9,7 @@ from basic_memory import db
from .routers import documents
from .routers import knowledge
from .routers import discovery
from .routers.activity_router import router as activity_router
@asynccontextmanager
@@ -32,3 +33,4 @@ app = FastAPI(
app.include_router(knowledge.router)
app.include_router(documents.router)
app.include_router(discovery.router)
app.include_router(activity_router)
@@ -0,0 +1,50 @@
"""Activity router for tracking recent changes."""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from loguru import logger
from basic_memory.deps import get_activity_service
from basic_memory.services.activity_service import ActivityService
from basic_memory.schemas.activity import RecentActivity, ActivityType
router = APIRouter(
prefix="/activity",
tags=["activity"]
)
@router.get(
"/recent",
response_model=RecentActivity,
summary="Get recent activity"
)
async def get_recent_activity(
activity_service: ActivityService = Depends(get_activity_service),
timeframe: str = "1d",
activity_types: Optional[List[str]] = Query(None), # Use Query for array params
include_content: bool = True
) -> RecentActivity:
"""
Get recent activity across 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 with changes and summary
"""
logger.debug(
f"Getting recent activity (timeframe={timeframe}, "
f"types={activity_types}, include_content={include_content})"
)
return await activity_service.get_recent_activity(
timeframe=timeframe,
activity_types=activity_types,
include_content=include_content
)
+17
View File
@@ -23,6 +23,7 @@ from basic_memory.services import (
RelationService,
DocumentService,
)
from basic_memory.services.activity_service import ActivityService
from basic_memory.services.file_service import FileService
from basic_memory.services.knowledge import KnowledgeService
@@ -143,6 +144,22 @@ async def get_document_service(
DocumentServiceDep = Annotated[DocumentService, Depends(get_document_service)]
async def get_activity_service(
entity_service: EntityServiceDep,
document_service: DocumentServiceDep,
relation_service: RelationServiceDep,
) -> ActivityService:
"""Create ActivityService with dependencies."""
return ActivityService(
entity_service=entity_service,
document_service=document_service,
relation_service=relation_service
)
ActivityServiceDep = Annotated[ActivityService, Depends(get_activity_service)]
async def get_file_service() -> FileService:
return FileService()
+9
View File
@@ -10,6 +10,7 @@ from basic_memory.mcp.tools import knowledge # noqa: F401
from basic_memory.mcp.tools import search # noqa: F401
from basic_memory.mcp.tools import documents # noqa: F401
from basic_memory.mcp.tools import discovery # noqa: F401
from basic_memory.mcp.tools import activity # noqa: F401
# Export the tools
from basic_memory.mcp.tools.knowledge import (
@@ -40,6 +41,11 @@ from basic_memory.mcp.tools.discovery import (
get_observation_categories,
)
from basic_memory.mcp.tools.activity import (
get_recent_activity,
)
__all__ = [
# Knowledge graph tools
"create_entities",
@@ -64,4 +70,7 @@ __all__ = [
# Discovery tools
"get_entity_types",
"get_observation_categories",
# Activity tools
"get_recent_activity",
]
+60
View File
@@ -0,0 +1,60 @@
"""Tools for tracking activity and changes in the knowledge base."""
from typing import List, Optional
from loguru import logger
from basic_memory.mcp.async_client import client
from basic_memory.mcp.server import mcp
from basic_memory.schemas.activity import ActivityType, RecentActivity
@mcp.tool()
async def get_recent_activity(
timeframe: str = "1d",
activity_types: Optional[List[ActivityType]] = None,
include_content: bool = True
) -> RecentActivity:
"""
Get recent activity across your knowledge base.
Shows you what has changed recently including:
- Document changes
- Entity updates
- Relation modifications
You can filter by:
- Timeframe (e.g., 1h, 1d, 1w, 1m)
- Activity types (document, entity, relation)
- Whether to include content
Examples:
# Get all activity in last day
activity = await get_recent_activity()
# Get only document changes
docs = await get_recent_activity(
timeframe="1h",
activity_types=[ActivityType.DOCUMENT],
include_content=False
)
Returns:
RecentActivity object with changes and summary
"""
logger.debug(
f"Getting recent activity (timeframe={timeframe}, "
f"types={activity_types}, include_content={include_content})"
)
# Build params
params = {
"timeframe": timeframe,
"include_content": str(include_content).lower()
}
if activity_types:
params["activity_types"] = [t.value for t in activity_types] # Convert enums to values
# Get activity
response = await client.get("/activity/recent", params=params)
return RecentActivity.model_validate(response.json())
+18
View File
@@ -0,0 +1,18 @@
"""Document related schemas."""
from typing import Dict, Optional
from pydantic import BaseModel, Field
from basic_memory.schemas.request import DocumentPathId
class CreateDocumentRequest(BaseModel):
"""Request to create a new document."""
path_id: DocumentPathId = Field(..., description="Path to the document")
content: str = Field(..., description="Document content")
doc_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
class DocumentUpdate(BaseModel):
"""Request to update a document."""
content: Optional[str] = None
doc_metadata: Optional[Dict] = None
+28 -23
View File
@@ -1,4 +1,6 @@
from datetime import datetime
"""Service for tracking and querying activity across the knowledge base."""
from datetime import datetime, timezone
from typing import List, Optional, Sequence
from . import EntityService, DocumentService, RelationService
@@ -45,7 +47,7 @@ class ActivityService:
"""
# Parse timeframe and get cutoff date
tf = TimeFrame(timeframe)
since = datetime.utcnow() - tf.to_timedelta
since = datetime.now(timezone.utc) - tf.to_timedelta
# Get changes based on requested types
changes = []
@@ -63,7 +65,10 @@ class ActivityService:
elif activity_type == ActivityType.RELATION:
changes.extend(await self._get_relation_changes(since))
# Sort all changes by timestamp
# Sort all changes by timestamp, ensuring timezone awareness
for change in changes:
if change.timestamp.tzinfo is None:
change.timestamp = change.timestamp.replace(tzinfo=timezone.utc)
changes.sort(key=lambda x: x.timestamp, reverse=True)
# Remove content if not requested
@@ -92,17 +97,17 @@ class ActivityService:
changes = []
for entity in entities:
change_type = (
ChangeType.CREATED
if entity.created_at >= since
else ChangeType.UPDATED
)
# Ensure timestamps are timezone-aware
created_at = entity.created_at.replace(tzinfo=timezone.utc) if entity.created_at.tzinfo is None else entity.created_at
updated_at = entity.updated_at.replace(tzinfo=timezone.utc) if entity.updated_at.tzinfo is None else entity.updated_at
change_type = ChangeType.CREATED if created_at >= since else ChangeType.UPDATED
changes.append(
ActivityChange(
activity_type=ActivityType.ENTITY,
change_type=change_type,
timestamp=entity.updated_at,
timestamp=updated_at,
path_id=entity.path_id,
summary=f"{change_type.value.title()} entity: {entity.name}",
content=entity.description
@@ -118,20 +123,20 @@ class ActivityService:
changes = []
for doc in documents:
change_type = (
ChangeType.CREATED
if doc.created_at >= since
else ChangeType.UPDATED
)
# Ensure timestamps are timezone-aware
created_at = doc.created_at.replace(tzinfo=timezone.utc) if doc.created_at.tzinfo is None else doc.created_at
updated_at = doc.updated_at.replace(tzinfo=timezone.utc) if doc.updated_at.tzinfo is None else doc.updated_at
change_type = ChangeType.CREATED if created_at >= since else ChangeType.UPDATED
changes.append(
ActivityChange(
activity_type=ActivityType.DOCUMENT,
change_type=change_type,
timestamp=doc.updated_at,
timestamp=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
content=None # For documents we don't include content by default
)
)
@@ -144,17 +149,17 @@ class ActivityService:
changes = []
for relation in relations:
change_type = (
ChangeType.CREATED
if relation.created_at >= since
else ChangeType.UPDATED
)
# Ensure timestamps are timezone-aware
created_at = relation.created_at.replace(tzinfo=timezone.utc) if relation.created_at.tzinfo is None else relation.created_at
updated_at = relation.updated_at.replace(tzinfo=timezone.utc) if relation.updated_at.tzinfo is None else relation.updated_at
change_type = ChangeType.CREATED if created_at >= since else ChangeType.UPDATED
changes.append(
ActivityChange(
activity_type=ActivityType.RELATION,
change_type=change_type,
timestamp=relation.updated_at,
timestamp=updated_at,
path_id=f"{relation.from_id}->{relation.to_id}",
summary=(
f"{change_type.value.title()} relation: "
@@ -178,4 +183,4 @@ class ActivityService:
key=lambda x: (-x[1], x[0]) # Sort by count desc, then path asc
)
return [path for path, _ in sorted_paths[:limit]]
return [path for path, _ in sorted_paths[:limit]]
+45
View File
@@ -0,0 +1,45 @@
"""Test activity router."""
import pytest
from httpx import AsyncClient
from basic_memory.schemas.activity import ActivityType
@pytest.mark.anyio
async def test_get_recent_activity(client: AsyncClient):
"""Test getting recent activity."""
# Get initial activity
response = await client.get("/activity/recent")
assert response.status_code == 200
# Parse response
data = response.json()
assert "changes" in data
assert "summary" in data
assert "timeframe" in data
assert data["timeframe"] == "1d" # Default timeframe
@pytest.mark.anyio
async def test_get_recent_activity_with_filters(client: AsyncClient):
"""Test getting recent activity with filters."""
# Get activity with filters
response = await client.get(
"/activity/recent",
params={
"timeframe": "1h",
"activity_types": [ActivityType.DOCUMENT.value],
"include_content": False
}
)
assert response.status_code == 200
# Parse response
data = response.json()
assert data["timeframe"] == "1h"
# Verify all changes are document type
for change in data["changes"]:
assert change["activity_type"] == ActivityType.DOCUMENT.value
assert change["content"] is None # Content excluded
+138
View File
@@ -0,0 +1,138 @@
"""Tests for activity tracking MCP tool."""
import pytest
from basic_memory.mcp.tools.documents import create_document
from basic_memory.mcp.tools.knowledge import create_entities
from basic_memory.mcp.tools.activity import get_recent_activity
from basic_memory.schemas.base import Entity
from basic_memory.schemas.request import CreateEntityRequest, DocumentRequest
from basic_memory.schemas.activity import ActivityType
@pytest.mark.asyncio
async def test_get_recent_activity_basic(client):
"""Test getting activity after creating some documents."""
# Create test documents
doc1 = await create_document(
DocumentRequest(
path_id="test/doc1.md",
content="Test document 1",
doc_metadata={}
)
)
doc2 = await create_document(
DocumentRequest(
path_id="test/doc2.md",
content="Test document 2",
doc_metadata={}
)
)
# Get recent activity
result = await get_recent_activity()
# Should find both documents
assert len(result.changes) == 2
assert result.summary.document_changes == 2
assert result.summary.entity_changes == 0
assert result.summary.relation_changes == 0
# Should be sorted by timestamp
timestamps = [change.timestamp for change in result.changes]
assert timestamps == sorted(timestamps, reverse=True)
# Should have both documents
paths = {change.path_id for change in result.changes}
assert "test/doc1.md" in paths
assert "test/doc2.md" in paths
@pytest.mark.asyncio
async def test_get_recent_activity_filtered(client):
"""Test activity filtering by type."""
# Create both document and entity changes
doc = await create_document(
DocumentRequest(
path_id="test/filtered_doc.md",
content="Test document",
doc_metadata={}
)
)
entity_request = CreateEntityRequest(
entities=[Entity(name="TestEntity", entity_type="test")]
)
entity = await create_entities(entity_request)
# Get only document changes
result = await get_recent_activity(
timeframe="1d",
activity_types=[ActivityType.DOCUMENT] # Use enum directly
)
# Should only find document changes
assert len(result.changes) == 1
assert result.summary.document_changes == 1
assert result.summary.entity_changes == 0
assert result.changes[0].activity_type == ActivityType.DOCUMENT
assert result.changes[0].path_id == "test/filtered_doc.md"
@pytest.mark.asyncio
async def test_get_recent_activity_without_content(client):
"""Test getting activity without content."""
# Create test document
doc = await create_document(
DocumentRequest(
path_id="test/no_content_doc.md",
content="This content should not appear",
doc_metadata={}
)
)
# Get activity without content
result = await get_recent_activity(
timeframe="1d",
include_content=False
)
# Should find doc but without content
assert len(result.changes) == 1
assert result.changes[0].content is None
@pytest.mark.asyncio
async def test_get_recent_activity_multiple_types(client):
"""Test tracking activity across all types."""
# Create changes of different types
doc = await create_document(
DocumentRequest(
path_id="test/activity_doc.md",
content="Test document",
doc_metadata={}
)
)
entity_request = CreateEntityRequest(
entities=[
Entity(name="Entity1", entity_type="test"),
Entity(name="Entity2", entity_type="test")
]
)
entities = await create_entities(entity_request)
# Get all activity
result = await get_recent_activity()
# Should find all changes
assert result.summary.document_changes == 1
assert result.summary.entity_changes == 2
assert result.summary.relation_changes == 0 # No relations created
# Changes should be the right types
doc_changes = [c for c in result.changes if c.activity_type == ActivityType.DOCUMENT]
entity_changes = [c for c in result.changes if c.activity_type == ActivityType.ENTITY]
assert len(doc_changes) == 1
assert len(entity_changes) == 2
+78 -76
View File
@@ -1,75 +1,102 @@
"""Test activity service."""
import os.path
from datetime import datetime, timedelta, timezone
import tempfile
from pathlib import Path
import pytest
from basic_memory.services.activity_service import ActivityService
from basic_memory.services.document_service import DocumentService
from basic_memory.services.entity_service import EntityService
from basic_memory.services.relation_service import RelationService
from basic_memory.repository.document_repository import DocumentRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.relation_repository import RelationRepository
async def create_test_document(
service: DocumentService, name: str, created_delta: timedelta, updated_delta: timedelta
):
"""Helper to create document with specific timestamps."""
now = datetime.now(timezone.utc)
doc = await service.create_document(
path_id=f"test/{name}.md",
content=f"Content for {name}",
metadata={
"created": (now - created_delta).isoformat(),
"updated": (now - updated_delta).isoformat(),
},
)
return doc
@pytest.fixture
def test_dir():
"""Create a temporary test directory."""
with tempfile.TemporaryDirectory() as tmpdir:
docs_dir = os.path.join(tmpdir, 'documents')
os.makedirs(docs_dir)
yield Path(tmpdir)
@pytest.fixture
def document_service(session_maker, test_dir):
"""Create document service."""
docs_path = test_dir / 'documents'
repository = DocumentRepository(session_maker)
return DocumentService(repository, docs_path)
@pytest.fixture
def entity_service(session_maker):
"""Create entity service."""
repository = EntityRepository(session_maker)
return EntityService(repository)
@pytest.fixture
def relation_service(session_maker):
"""Create relation service."""
repository = RelationRepository(session_maker)
return RelationService(repository)
@pytest.fixture
def activity_service(document_service, entity_service, relation_service):
"""Create activity service with real dependencies."""
return ActivityService(entity_service, document_service, relation_service)
async def create_test_document(service: DocumentService, name: str, content: str = "") -> str:
"""Create a test document."""
path_id = f"test/{name}.md"
doc_content = content or f"Content for {name}"
return await service.create_document(path_id, doc_content)
@pytest.mark.asyncio
async def test_get_recent_activity_all_types(activity_service, document_service):
"""Test getting recent activity for all types."""
# Create test documents with various timestamps
test_docs = [
await create_test_document(
document_service, "doc1", timedelta(hours=2), timedelta(hours=2)
),
await create_test_document(document_service, "doc2", timedelta(days=2), timedelta(hours=1)),
await create_test_document(
document_service,
"doc3",
timedelta(days=3),
timedelta(days=3), # This one should be too old
),
]
"""Test finding recently created documents."""
# Create test documents
doc1 = await create_test_document(document_service, "doc1")
doc2 = await create_test_document(document_service, "doc2")
# Get activity from last day
result = await activity_service.get_recent_activity(timeframe="1d")
# Verify results
assert len(result.changes) == 2 # Should only find 2 recent docs
# Should find both docs
assert len(result.changes) == 2
assert result.summary.document_changes == 2
assert result.summary.entity_changes == 0
assert result.summary.relation_changes == 0
# Verify changes are sorted by timestamp (most recent first)
# Changes should be sorted by timestamp (most recent first)
timestamps = [change.timestamp for change in result.changes]
assert timestamps == sorted(timestamps, reverse=True)
# Check specific document paths
paths = {change.path_id for change in result.changes}
assert "test/doc1.md" in paths
assert "test/doc2.md" in paths
assert "test/doc3.md" not in paths # Too old to be included
@pytest.mark.asyncio
async def test_get_recent_activity_filtered_types(activity_service, document_service):
"""Test getting recent activity with type filtering."""
# Create some test documents
await create_test_document(
document_service, "filtered_doc", timedelta(hours=1), timedelta(hours=1)
)
"""Test activity filtering by type."""
# Create test document
await create_test_document(document_service, "filtered_doc")
# Get activity filtered to only documents
result = await activity_service.get_recent_activity(timeframe="1d", activity_types=["document"])
result = await activity_service.get_recent_activity(
timeframe="1d",
activity_types=["document"]
)
# Verify results
# Should find our test doc
assert len(result.changes) == 1
assert result.summary.document_changes == 1
assert result.summary.entity_changes == 0
@@ -79,44 +106,19 @@ async def test_get_recent_activity_filtered_types(activity_service, document_ser
@pytest.mark.asyncio
async def test_get_recent_activity_without_content(activity_service, document_service):
"""Test getting activity without content."""
# Create test document
# Create test document with content
await create_test_document(
document_service, "no_content_doc", timedelta(hours=1), timedelta(hours=1)
document_service,
"no_content_doc",
content="This content should not appear"
)
# Get activity without content
result = await activity_service.get_recent_activity(timeframe="1d", include_content=False)
result = await activity_service.get_recent_activity(
timeframe="1d",
include_content=False
)
# Verify results
# Should find doc but without content
assert len(result.changes) == 1
assert result.changes[0].content is None
@pytest.mark.asyncio
async def test_change_type_detection(activity_service, document_service):
"""Test correct detection of created vs updated changes."""
now = datetime.now(timezone.utc)
# Create documents with different creation/update patterns
new_doc = await create_test_document(
document_service,
"new_doc",
timedelta(hours=1), # Created recently
timedelta(hours=1), # Updated recently
)
updated_doc = await create_test_document(
document_service,
"updated_doc",
timedelta(days=2), # Created a while ago
timedelta(hours=2), # But updated recently
)
# Get activity
result = await activity_service.get_recent_activity(timeframe="1d")
# Verify change types
changes_by_path = {change.path_id: change for change in result.changes}
assert changes_by_path["test/new_doc.md"].change_type == "created"
assert changes_by_path["test/updated_doc.md"].change_type == "updated"
assert result.changes[0].content is None