mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
remove activity router, replace with memory/recent
This commit is contained in:
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from .routers import knowledge, discovery, search, activity, memory, resource
|
||||
from .routers import knowledge, discovery, search, memory, resource
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -29,7 +29,6 @@ app = FastAPI(
|
||||
# Include routers
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(discovery.router)
|
||||
app.include_router(activity.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(memory.router)
|
||||
app.include_router(resource.router)
|
||||
@@ -4,7 +4,6 @@ from . import knowledge_router as knowledge
|
||||
from . import discovery_router as discovery
|
||||
from . import memory_router as memory
|
||||
from . import resource_router as resource
|
||||
from . import activity_router as activity
|
||||
from . import search_router as search
|
||||
|
||||
__all__ = ["knowledge", "discovery", "memory", "resource", "activity", "search"]
|
||||
__all__ = ["knowledge", "discovery", "memory", "resource", "search"]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""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[ActivityType]] = Query(None),
|
||||
) -> RecentActivity:
|
||||
"""
|
||||
Get recent activity across the knowledge base.
|
||||
|
||||
Args:
|
||||
timeframe: Time window to look back (1h, 1d, 1w, 1m)
|
||||
activity_types: Optional list of ActivityType values to include
|
||||
|
||||
Returns:
|
||||
RecentActivity with changes and summary
|
||||
"""
|
||||
logger.debug(
|
||||
f"Getting recent activity (timeframe={timeframe}, "
|
||||
f"types={activity_types})"
|
||||
)
|
||||
|
||||
return await activity_service.get_recent_activity(
|
||||
timeframe=timeframe,
|
||||
activity_types=[t.value for t in activity_types] if activity_types else None,
|
||||
)
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Routes for memory:// URI operations."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Annotated
|
||||
|
||||
from fastapi import APIRouter
|
||||
from dateparser import parse
|
||||
from fastapi import APIRouter, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.config import config
|
||||
from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
MemoryUrl,
|
||||
GraphContext,
|
||||
@@ -23,49 +26,8 @@ from basic_memory.services.context_service import ContextResultRow
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
def parse_timeframe(timeframe: str) -> Optional[datetime]:
|
||||
"""Convert timeframe string to datetime.
|
||||
|
||||
Formats:
|
||||
- 7d: 7 days ago
|
||||
- 30d: 30 days ago
|
||||
- None: no time limit
|
||||
"""
|
||||
if not timeframe:
|
||||
return None
|
||||
|
||||
if not timeframe.endswith("d"):
|
||||
raise ValueError("Timeframe must be in days (e.g., '7d')")
|
||||
|
||||
days = int(timeframe[:-1])
|
||||
return datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
|
||||
@router.get("/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: str = "7d",
|
||||
max_results: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
logger.debug(
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
)
|
||||
memory_url = MemoryUrl(f"memory://{config.project}/{uri}")
|
||||
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe)
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, max_results=max_results
|
||||
)
|
||||
|
||||
async def to_graph_context(context, entity_repository: EntityRepository):
|
||||
# return results
|
||||
async def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
@@ -81,10 +43,9 @@ async def get_memory_context(
|
||||
category=item.category, content=item.content, permalink=item.permalink
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
|
||||
from_entity = await entity_repository.find_by_id(item.from_id)
|
||||
to_entity = await entity_repository.find_by_id(item.to_id)
|
||||
|
||||
|
||||
return RelationSummary(
|
||||
permalink=item.permalink,
|
||||
type=item.type,
|
||||
@@ -96,9 +57,69 @@ async def get_memory_context(
|
||||
primary_results = [await to_summary(r) for r in context["primary_results"]]
|
||||
related_results = [await to_summary(r) for r in context["related_results"]]
|
||||
metadata = MemoryMetadata.model_validate(context["metadata"])
|
||||
|
||||
# Transform to GraphContext
|
||||
return GraphContext(
|
||||
primary_results=primary_results, related_results=related_results, metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
types: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
) -> GraphContext:
|
||||
# return all types by default
|
||||
types = (
|
||||
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
|
||||
if not types
|
||||
else types
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, max_results=max_results
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
|
||||
@router.get("/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
max_results: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
logger.debug(
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` max_results: `{max_results}`"
|
||||
)
|
||||
memory_url = MemoryUrl(f"memory://{config.project}/{uri}")
|
||||
|
||||
# Parse timeframe
|
||||
since = parse(timeframe)
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, max_results=max_results
|
||||
)
|
||||
return await to_graph_context(context, entity_repository=entity_repository)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from basic_memory.services import (
|
||||
ObservationService,
|
||||
RelationService,
|
||||
)
|
||||
from basic_memory.services.activity_service import ActivityService
|
||||
from basic_memory.services.context_service import ContextService
|
||||
from basic_memory.services.file_service import FileService
|
||||
from basic_memory.services.search_service import SearchService
|
||||
@@ -166,20 +165,6 @@ async def get_search_service(
|
||||
SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
|
||||
|
||||
|
||||
async def get_activity_service(
|
||||
entity_service: EntityServiceDep,
|
||||
relation_service: RelationServiceDep,
|
||||
) -> ActivityService:
|
||||
"""Create ActivityService with dependencies."""
|
||||
return ActivityService(
|
||||
entity_service=entity_service,
|
||||
relation_service=relation_service,
|
||||
)
|
||||
|
||||
|
||||
ActivityServiceDep = Annotated[ActivityService, Depends(get_activity_service)]
|
||||
|
||||
|
||||
async def get_knowledge_writer() -> KnowledgeWriter:
|
||||
return KnowledgeWriter()
|
||||
|
||||
|
||||
@@ -5,15 +5,8 @@ Basic Memory through the MCP protocol. Importing this module registers
|
||||
all tools with the MCP server.
|
||||
"""
|
||||
|
||||
from basic_memory.mcp.tools import activity # noqa: F401
|
||||
|
||||
# Import tools to register them with MCP
|
||||
from basic_memory.mcp.tools import knowledge # noqa: F401
|
||||
from basic_memory.mcp.tools import search # noqa: F401
|
||||
from basic_memory.mcp.tools.activity import (
|
||||
get_recent_activity,
|
||||
)
|
||||
from basic_memory.mcp.tools.memory import build_context
|
||||
from basic_memory.mcp.tools.memory import build_context, recent_activity
|
||||
from basic_memory.mcp.tools.ai_edit import ai_edit
|
||||
|
||||
# Export the tools
|
||||
@@ -40,10 +33,9 @@ __all__ = [
|
||||
"get_entities",
|
||||
# Search tools
|
||||
"search",
|
||||
# Activity tools
|
||||
"get_recent_activity",
|
||||
# memory tools
|
||||
"build_context",
|
||||
"recent_activity",
|
||||
# file edit
|
||||
"ai_edit",
|
||||
]
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Tools for tracking activity and changes in the knowledge base."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
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(
|
||||
description="Track recent changes to documents, entities, and relations",
|
||||
)
|
||||
async def get_recent_activity(
|
||||
context: Context,
|
||||
timeframe: str = "1d",
|
||||
activity_types: Optional[List[ActivityType]] = None,
|
||||
) -> RecentActivity:
|
||||
"""Track changes across the knowledge base.
|
||||
|
||||
Args:
|
||||
timeframe: Time window to analyze ("1h", "1d", "1w")
|
||||
activity_types: Optional list of types to filter by
|
||||
context: MCP context
|
||||
|
||||
Returns:
|
||||
RecentActivity object with changes and summary statistics
|
||||
"""
|
||||
context.info(f"Getting recent activity (timeframe={timeframe}, types={activity_types})")
|
||||
|
||||
# Build params
|
||||
params = {
|
||||
"timeframe": timeframe,
|
||||
}
|
||||
if activity_types:
|
||||
params["activity_types"] = [t.value for t in activity_types]
|
||||
|
||||
# Get activity
|
||||
response = await client.get("/activity/recent", params=params)
|
||||
return RecentActivity.model_validate(response.json())
|
||||
@@ -1,19 +1,34 @@
|
||||
"""Discussion context tools for Basic Memory MCP server."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.mcp.async_client import client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.schemas.memory import GraphContext, MemoryUrl
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Build context from a memory:// URI to continue conversations naturally.",
|
||||
description="""Build context from a memory:// URI to continue conversations naturally.
|
||||
|
||||
Use this to follow up on previous discussions or explore related topics.
|
||||
Timeframes use natural language support - examples:
|
||||
- "2 days ago"
|
||||
- "last week"
|
||||
- "today"
|
||||
- "3 months ago"
|
||||
Or standard formats like "7d", "24h"
|
||||
""",
|
||||
)
|
||||
async def build_context(
|
||||
url: MemoryUrl, depth: Optional[int] = 1, timeframe: Optional[str] = "7d", max_results: int = 10
|
||||
url: MemoryUrl,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
max_results: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get context needed to continue a discussion.
|
||||
|
||||
@@ -22,23 +37,97 @@ async def build_context(
|
||||
a rich context graph of related information.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
|
||||
depth: How many relation hops to traverse (default: 2)
|
||||
timeframe: How far back to look, e.g. "7d", "24h" (default: "7d")
|
||||
max_results: The maximum number of results to return (default: 10)
|
||||
depth: How many relation hops to traverse (1-3 recommended for performance)
|
||||
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
- primary_results: Directly matched content
|
||||
- primary_results: Content matching the memory:// URI
|
||||
- related_results: Connected content via relations
|
||||
- metadata: Context building info
|
||||
- metadata: Context building details
|
||||
|
||||
Examples:
|
||||
# Continue a specific discussion
|
||||
build_context("memory://specs/search")
|
||||
|
||||
# Get deeper context about a component
|
||||
build_context("memory://components/memory-service", depth=2)
|
||||
|
||||
# Look at recent changes to a specification
|
||||
build_context("memory://specs/document-format", timeframe="today")
|
||||
|
||||
# Research the history of a feature
|
||||
build_context("memory://features/knowledge-graph", timeframe="3 months ago")
|
||||
"""
|
||||
logger.info(f"Building context from {url}")
|
||||
# Map directly to the memory endpoint
|
||||
memory_url = MemoryUrl.validate(url)
|
||||
response = await client.get(
|
||||
response = await call_get(
|
||||
client,
|
||||
f"/memory/{memory_url.relative_path()}",
|
||||
params={"depth": depth, "timeframe": timeframe, "max_results": max_results},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="""Get recent activity from across the knowledge base.
|
||||
|
||||
Timeframe supports natural language formats like:
|
||||
- "2 days ago"
|
||||
- "last week"
|
||||
- "3 weeks"
|
||||
- "2 months"
|
||||
- "yesterday"
|
||||
- "today"
|
||||
Or standard formats like "7d", "24h"
|
||||
""",
|
||||
)
|
||||
async def recent_activity(
|
||||
types: List[SearchItemType] = None,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
max_results: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get recent activity across the knowledge base.
|
||||
|
||||
Args:
|
||||
types: Filter by entity types (["entity", "relation", "observation"]). If None, returns all types.
|
||||
depth: How many relation hops to traverse when building context (1-3 recommended)
|
||||
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
|
||||
Returns:
|
||||
GraphContext containing:
|
||||
- primary_results: Latest activities matching the filters
|
||||
- related_results: Connected content via relations
|
||||
- metadata: Query details and statistics
|
||||
|
||||
Examples:
|
||||
# Get all activity from last week
|
||||
recent_activity(timeframe="last week")
|
||||
|
||||
# Get only entity changes from yesterday
|
||||
recent_activity(types=["entity"], timeframe="yesterday")
|
||||
|
||||
# Track recent specification changes
|
||||
recent_activity(types=["entity"], depth=2, timeframe="3 days ago")
|
||||
|
||||
# Follow recent relation changes
|
||||
recent_activity(types=["relation"], timeframe="today")
|
||||
|
||||
Notes:
|
||||
- Higher depth values (>3) may impact performance with large result sets
|
||||
- For focused queries, consider using build_context with a specific URI
|
||||
- Max timeframe is 1 year in the past
|
||||
"""
|
||||
logger.info(
|
||||
f"Getting recent activity from {types}, depth={depth}, timeframe={timeframe}, max_results={max_results}"
|
||||
)
|
||||
response = await client.get(
|
||||
"/memory/recent",
|
||||
params={"depth": depth, "timeframe": timeframe, "max_results": max_results, "types": types},
|
||||
)
|
||||
return GraphContext.model_validate(response.json())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Repository for search operations."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Any, Dict
|
||||
@@ -95,6 +96,7 @@ class SearchRepository:
|
||||
types: List[SearchItemType] = None,
|
||||
after_date: datetime = None,
|
||||
entity_types: List[str] = None,
|
||||
limit: int = 10,
|
||||
) -> List[SearchIndexRow]:
|
||||
"""Search across all indexed content with fuzzy matching."""
|
||||
conditions = []
|
||||
@@ -131,6 +133,9 @@ class SearchRepository:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("datetime(created_at) > datetime(:after_date)")
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
|
||||
# Build WHERE clause
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
@@ -154,6 +159,7 @@ class SearchRepository:
|
||||
FROM search_index
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
#logger.debug(f"Search {sql} params: {params}")
|
||||
@@ -182,7 +188,7 @@ class SearchRepository:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.debug(f"Search results: {results}")
|
||||
#logger.debug(f"Search results: {results}")
|
||||
return results
|
||||
|
||||
async def index_item(
|
||||
@@ -234,9 +240,12 @@ class SearchRepository:
|
||||
"""Execute a query asynchronously."""
|
||||
#logger.debug(f"Executing query: {query}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
start_time = time.perf_counter()
|
||||
if params:
|
||||
result = await session.execute(query, params)
|
||||
else:
|
||||
result = await session.execute(query)
|
||||
logger.debug("Query executed successfully")
|
||||
end_time = time.perf_counter()
|
||||
elapsed_time = end_time - start_time
|
||||
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
|
||||
return result
|
||||
@@ -1,85 +0,0 @@
|
||||
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."""
|
||||
|
||||
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
|
||||
permalink: str
|
||||
summary: str
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class ActivitySummary(BaseModel):
|
||||
"""Summary statistics about recent activity."""
|
||||
|
||||
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
|
||||
@@ -13,10 +13,13 @@ Key Concepts:
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Annotated, Dict
|
||||
|
||||
from annotated_types import MinLen, MaxLen
|
||||
from dateparser import parse
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, Field, model_validator, ValidationError
|
||||
|
||||
from basic_memory.utils import generate_permalink
|
||||
@@ -81,6 +84,36 @@ class ObservationCategory(str, Enum):
|
||||
return None
|
||||
|
||||
|
||||
def validate_timeframe(timeframe: str) -> str:
|
||||
"""Convert human readable timeframes to a duration relative to the current time."""
|
||||
if not isinstance(timeframe, str):
|
||||
raise ValueError("Timeframe must be a string")
|
||||
|
||||
# Parse relative time expression
|
||||
parsed = parse(timeframe)
|
||||
if not parsed:
|
||||
raise ValueError(f"Could not parse timeframe: {timeframe}")
|
||||
|
||||
# Convert to duration
|
||||
now = datetime.now()
|
||||
if parsed > now:
|
||||
raise ValueError("Timeframe cannot be in the future")
|
||||
|
||||
# Could format the duration back to our standard format
|
||||
days = (now - parsed).days
|
||||
|
||||
# Could enforce reasonable limits
|
||||
if days > 365:
|
||||
raise ValueError("Timeframe should be <= 1 year")
|
||||
|
||||
return f"{days}d"
|
||||
|
||||
|
||||
TimeFrame = Annotated[
|
||||
str,
|
||||
BeforeValidator(validate_timeframe)
|
||||
]
|
||||
|
||||
PathId = Annotated[str, BeforeValidator(validate_path_format)]
|
||||
"""Unique identifier in format '{path}/{normalized_name}'."""
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Schemas for memory context."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from pydantic import AnyUrl, Field, BaseModel
|
||||
|
||||
from basic_memory.config import config
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
"""Memory URL schema for knowledge addressing.
|
||||
|
||||
@@ -77,7 +78,8 @@ class ObservationSummary(BaseModel):
|
||||
class MemoryMetadata(BaseModel):
|
||||
"""Simplified response metadata."""
|
||||
|
||||
uri: str
|
||||
uri: Optional[str] = None
|
||||
types: Optional[List[SearchItemType]] = None
|
||||
depth: int
|
||||
timeframe: str
|
||||
generated_at: datetime
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""Service for tracking and querying activity across the knowledge base."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from . import EntityService, RelationService
|
||||
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,
|
||||
relation_service: RelationService,
|
||||
):
|
||||
"""Initialize with required services."""
|
||||
self.entity_service = entity_service
|
||||
self.relation_service = relation_service
|
||||
|
||||
async def get_recent_activity(
|
||||
self,
|
||||
timeframe: str = "1d",
|
||||
activity_types: Optional[List[str]] = None,
|
||||
) -> 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
|
||||
|
||||
Returns:
|
||||
RecentActivity object containing changes and summary
|
||||
"""
|
||||
# Parse timeframe and get cutoff date
|
||||
tf = TimeFrame(timeframe)
|
||||
since = datetime.now(timezone.utc) - 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.RELATION:
|
||||
changes.extend(await self._get_relation_changes(since))
|
||||
|
||||
# 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)
|
||||
|
||||
# Generate summary
|
||||
summary = ActivitySummary(
|
||||
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:
|
||||
# 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=updated_at,
|
||||
permalink=entity.permalink,
|
||||
summary=f"{change_type.value.title()} entity: {entity.title}",
|
||||
content=entity.summary,
|
||||
)
|
||||
)
|
||||
|
||||
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:
|
||||
# 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=updated_at,
|
||||
permalink=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.permalink] = path_counts.get(change.permalink, 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]]
|
||||
@@ -50,7 +50,8 @@ class ContextService:
|
||||
|
||||
async def build_context(
|
||||
self,
|
||||
memory_url: MemoryUrl,
|
||||
memory_url: MemoryUrl = None,
|
||||
types: List[SearchItemType] = None,
|
||||
depth: int = 1,
|
||||
since: Optional[datetime] = None,
|
||||
max_results: int = 10,
|
||||
@@ -60,22 +61,26 @@ class ContextService:
|
||||
f"Building context for URI: '{memory_url}' depth: '{depth}' since: '{since}' max_results: '{max_results}'"
|
||||
)
|
||||
|
||||
# Pattern matching - use search
|
||||
if "*" in memory_url.relative_path():
|
||||
logger.debug(f"Pattern search for '{memory_url.relative_path()}'")
|
||||
primary = await self.search_repository.search(
|
||||
permalink_match=memory_url.relative_path()
|
||||
)
|
||||
|
||||
# Direct lookup for exact path
|
||||
if memory_url:
|
||||
# Pattern matching - use search
|
||||
if "*" in memory_url.relative_path():
|
||||
logger.debug(f"Pattern search for '{memory_url.relative_path()}'")
|
||||
primary = await self.search_repository.search(
|
||||
permalink_match=memory_url.relative_path()
|
||||
)
|
||||
|
||||
# Direct lookup for exact path
|
||||
else:
|
||||
logger.debug(f"Direct lookup for '{memory_url.relative_path()}'")
|
||||
primary = await self.search_repository.search(permalink=memory_url.relative_path())
|
||||
else:
|
||||
logger.debug(f"Direct lookup for '{memory_url.relative_path()}'")
|
||||
primary = await self.search_repository.search(permalink=memory_url.relative_path())
|
||||
logger.debug(f"Build context for '{types}'")
|
||||
primary = await self.search_repository.search(types=types)
|
||||
|
||||
# Get type_id pairs for traversal
|
||||
|
||||
type_id_pairs = [(r.type, r.id) for r in primary] if primary else []
|
||||
logger.debug(f"primary type_id_pairs: {type_id_pairs}")
|
||||
logger.debug(f"found primary type_id_pairs: {len(type_id_pairs)}")
|
||||
|
||||
# Find related content
|
||||
related = await self.find_related(
|
||||
@@ -83,14 +88,15 @@ class ContextService:
|
||||
)
|
||||
logger.debug(f"Found {len(related)} related results")
|
||||
for r in related:
|
||||
logger.debug(f"Found related result: {r}")
|
||||
logger.debug(f"Found related {r.type}: {r.permalink}")
|
||||
|
||||
# Build response
|
||||
return {
|
||||
"primary_results": primary,
|
||||
"related_results": related,
|
||||
"metadata": {
|
||||
"uri": memory_url.relative_path(),
|
||||
"uri": memory_url.relative_path() if memory_url else None,
|
||||
"types": types if types else None,
|
||||
"depth": depth,
|
||||
"timeframe": since.isoformat() if since else None,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -117,7 +123,7 @@ class ContextService:
|
||||
if not type_id_pairs:
|
||||
return []
|
||||
|
||||
logger.debug(f"Finding connected items for {type_id_pairs} with depth {max_depth}")
|
||||
logger.debug(f"Finding connected items for {len(type_id_pairs)} with depth {max_depth}")
|
||||
|
||||
# Build the VALUES clause directly since SQLite doesn't handle parameterized IN well
|
||||
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
|
||||
|
||||
Reference in New Issue
Block a user