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:
+2
-1
@@ -38,4 +38,5 @@ projects/*.db-journal
|
||||
|
||||
**/.DS_Store
|
||||
|
||||
*.log
|
||||
*.log
|
||||
/.coverage.*
|
||||
|
||||
+2
-1
@@ -13,7 +13,7 @@ dependencies = [
|
||||
"greenlet>=3.1.1",
|
||||
"pydantic[email,timezone]>=2.10.3",
|
||||
"icecream>=2.1.3",
|
||||
"mcp>=1.1.0",
|
||||
"mcp>=1.2.0",
|
||||
"pydantic-settings>=2.6.1",
|
||||
"loguru>=0.7.3",
|
||||
"pyright>=1.1.390",
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
"python-frontmatter>=1.1.0",
|
||||
"rich>=13.9.4",
|
||||
"unidecode>=1.3.8",
|
||||
"dateparser>=1.2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""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.ENTITY.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.ENTITY.value
|
||||
assert change["content"] is None # Content excluded
|
||||
@@ -24,7 +24,6 @@ from basic_memory.services import (
|
||||
ObservationService,
|
||||
RelationService,
|
||||
)
|
||||
from basic_memory.services.activity_service import ActivityService
|
||||
from basic_memory.services.file_service import FileService
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
@@ -158,11 +157,6 @@ def file_change_scanner(entity_repository) -> FileChangeScanner:
|
||||
return FileChangeScanner(entity_repository)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def activity_service(entity_service, relation_service):
|
||||
"""Create activity service with real dependencies."""
|
||||
return ActivityService(entity_service, relation_service)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def entity_sync_service(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Tests for discussion context MCP tool."""
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.tools.memory import build_context
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
from basic_memory.mcp.tools.memory import build_context, recent_activity
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext, MemoryUrl
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -61,3 +64,62 @@ async def test_get_discussion_context_not_found(client):
|
||||
assert isinstance(context, GraphContext)
|
||||
assert len(context.primary_results) == 0
|
||||
assert len(context.related_results) == 0
|
||||
|
||||
|
||||
# Test data for different timeframe formats
|
||||
valid_timeframes = [
|
||||
"7d", # Standard format
|
||||
"yesterday", # Natural language
|
||||
"0d", # Zero duration
|
||||
]
|
||||
|
||||
invalid_timeframes = [
|
||||
"invalid", # Nonsense string
|
||||
"tomorrow", # Future date
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_timeframe_formats(client, test_graph):
|
||||
"""Test that recent_activity accepts various timeframe formats."""
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await recent_activity(
|
||||
types=["entity"],
|
||||
timeframe=timeframe,
|
||||
max_results=1
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
|
||||
# Test invalid timeframes should raise ValidationError
|
||||
for timeframe in invalid_timeframes:
|
||||
with pytest.raises(ValueError):
|
||||
await recent_activity(timeframe=timeframe)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_timeframe_formats(client, test_graph):
|
||||
"""Test that build_context accepts various timeframe formats."""
|
||||
test_url = MemoryUrl.validate("memory://specs/test")
|
||||
|
||||
# Test each valid timeframe
|
||||
for timeframe in valid_timeframes:
|
||||
try:
|
||||
result = await build_context(
|
||||
url=test_url,
|
||||
timeframe=timeframe,
|
||||
max_results=1
|
||||
)
|
||||
assert result is not None
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed with valid timeframe '{timeframe}': {str(e)}")
|
||||
|
||||
# Test invalid timeframes should raise ValidationError
|
||||
for timeframe in invalid_timeframes:
|
||||
with pytest.raises(ToolError):
|
||||
await build_context(url=test_url, timeframe=timeframe)
|
||||
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
import pytest
|
||||
|
||||
from basic_memory.schemas.activity import (
|
||||
TimeFrame,
|
||||
ActivityType,
|
||||
ChangeType,
|
||||
ActivityChange,
|
||||
ActivitySummary,
|
||||
RecentActivity,
|
||||
)
|
||||
|
||||
|
||||
def test_timeframe_parsing():
|
||||
"""Test TimeFrame string parsing."""
|
||||
# Valid timeframes
|
||||
assert TimeFrame("1h").value == 1
|
||||
assert TimeFrame("1h").unit == "h"
|
||||
assert TimeFrame("24h").value == 24
|
||||
assert TimeFrame("7d").unit == "d"
|
||||
assert TimeFrame("4w").unit == "w"
|
||||
assert TimeFrame("2m").unit == "m"
|
||||
|
||||
# Invalid timeframes
|
||||
with pytest.raises(ValueError):
|
||||
TimeFrame("")
|
||||
with pytest.raises(ValueError):
|
||||
TimeFrame("h")
|
||||
with pytest.raises(ValueError):
|
||||
TimeFrame("abc")
|
||||
with pytest.raises(ValueError):
|
||||
TimeFrame("1x") # Invalid unit
|
||||
with pytest.raises(ValueError):
|
||||
TimeFrame("-1h") # Negative value
|
||||
|
||||
|
||||
def test_timeframe_to_timedelta():
|
||||
"""Test conversion to timedelta."""
|
||||
assert TimeFrame("1h").to_timedelta == timedelta(hours=1)
|
||||
assert TimeFrame("24h").to_timedelta == timedelta(hours=24)
|
||||
assert TimeFrame("1d").to_timedelta == timedelta(days=1)
|
||||
assert TimeFrame("1w").to_timedelta == timedelta(weeks=1)
|
||||
assert TimeFrame("1m").to_timedelta == timedelta(days=30) # Approximate
|
||||
|
||||
|
||||
def test_activity_change_model():
|
||||
"""Test ActivityChange model."""
|
||||
now = datetime.utcnow()
|
||||
change = ActivityChange(
|
||||
activity_type=ActivityType.ENTITY,
|
||||
change_type=ChangeType.CREATED,
|
||||
timestamp=now,
|
||||
permalink="test/path",
|
||||
summary="Created test document",
|
||||
content="Test content",
|
||||
)
|
||||
|
||||
assert change.activity_type == ActivityType.ENTITY
|
||||
assert change.change_type == ChangeType.CREATED
|
||||
assert change.timestamp == now
|
||||
assert change.permalink == "test/path"
|
||||
assert change.summary == "Created test document"
|
||||
assert change.content == "Test content"
|
||||
|
||||
|
||||
def test_activity_summary_model():
|
||||
"""Test ActivitySummary model."""
|
||||
summary = ActivitySummary(
|
||||
entity_changes=3, relation_changes=2, most_active_paths=["path1", "path2"]
|
||||
)
|
||||
|
||||
assert summary.entity_changes == 3
|
||||
assert summary.relation_changes == 2
|
||||
assert summary.most_active_paths == ["path1", "path2"]
|
||||
|
||||
|
||||
def test_recent_activity_model():
|
||||
"""Test RecentActivity model."""
|
||||
now = datetime.utcnow()
|
||||
change = ActivityChange(
|
||||
activity_type=ActivityType.ENTITY,
|
||||
change_type=ChangeType.CREATED,
|
||||
timestamp=now,
|
||||
permalink="test/path",
|
||||
summary="Created test document",
|
||||
)
|
||||
|
||||
summary = ActivitySummary(entity_changes=1)
|
||||
|
||||
activity = RecentActivity(timeframe="1d", changes=[change], summary=summary)
|
||||
|
||||
assert activity.timeframe == "1d"
|
||||
assert len(activity.changes) == 1
|
||||
assert activity.changes[0].permalink == "test/path"
|
||||
assert activity.summary.entity_changes == 1
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for Pydantic schema validation and conversion."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from pydantic import ValidationError, BaseModel
|
||||
|
||||
from basic_memory.schemas import (
|
||||
Entity,
|
||||
@@ -12,7 +12,7 @@ from basic_memory.schemas import (
|
||||
GetEntitiesRequest,
|
||||
RelationResponse,
|
||||
)
|
||||
from basic_memory.schemas.base import to_snake_case
|
||||
from basic_memory.schemas.base import to_snake_case, TimeFrame
|
||||
|
||||
|
||||
def test_entity_in_minimal():
|
||||
@@ -210,3 +210,35 @@ def test_permalink_generation():
|
||||
for input_data, expected_path in test_cases:
|
||||
entity = Entity.model_validate(input_data)
|
||||
assert entity.permalink == expected_path, f"Failed for input: {input_data}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timeframe,expected_valid",
|
||||
[
|
||||
("7d", True),
|
||||
("yesterday", True),
|
||||
("2 days ago", True),
|
||||
("last week", True),
|
||||
("3 weeks ago", True),
|
||||
("invalid", False),
|
||||
("tomorrow", False),
|
||||
("next week", False),
|
||||
("", False),
|
||||
("0d", True),
|
||||
],
|
||||
)
|
||||
def test_timeframe_validation(timeframe: str, expected_valid: bool):
|
||||
"""Test TimeFrame validation directly."""
|
||||
|
||||
class TimeFrameModel(BaseModel):
|
||||
timeframe: TimeFrame
|
||||
|
||||
if expected_valid:
|
||||
try:
|
||||
tf = TimeFrameModel.model_validate({"timeframe": timeframe})
|
||||
assert isinstance(tf.timeframe, str)
|
||||
except ValueError as e:
|
||||
pytest.fail(f"TimeFrame failed to validate '{timeframe}' with error: {e}")
|
||||
else:
|
||||
with pytest.raises(ValueError):
|
||||
tf = TimeFrameModel.model_validate({"timeframe": timeframe})
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_search_permalink_relation_wildcard(search_service, test_graph):
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/root/connects_to/*"))
|
||||
assert len(results) == 1
|
||||
permalinks = {r.permalink for r in results}
|
||||
assert "test/root/connects_to/test/connected1" in permalinks
|
||||
assert "test/root/connects-to/test/connected1" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
|
||||
|
||||
@@ -183,6 +183,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "basic-foundation" },
|
||||
{ name = "dateparser" },
|
||||
{ name = "greenlet" },
|
||||
{ name = "icecream" },
|
||||
{ name = "loguru" },
|
||||
@@ -217,11 +218,12 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "basic-foundation", editable = "../basic-foundation" },
|
||||
{ name = "dateparser", specifier = ">=1.2.0" },
|
||||
{ name = "greenlet", specifier = ">=3.1.1" },
|
||||
{ name = "icecream", specifier = ">=2.1.3" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "markdown-it-py", specifier = ">=3.0.0" },
|
||||
{ name = "mcp", specifier = ">=1.1.0" },
|
||||
{ name = "mcp", specifier = ">=1.2.0" },
|
||||
{ name = "pydantic", extras = ["email", "timezone"], specifier = ">=2.10.3" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.6.1" },
|
||||
{ name = "pyright", specifier = ">=1.1.390" },
|
||||
@@ -509,6 +511,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dateparser"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "regex" },
|
||||
{ name = "tzlocal" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/b2/f6b29ab17d7959eb1a0a5c64f5011dc85051ad4e25e401cbddcc515db00f/dateparser-1.2.0.tar.gz", hash = "sha256:7975b43a4222283e0ae15be7b4999d08c9a70e2d378ac87385b1ccf2cffbbb30", size = 307260 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/29/db12aa4dda81580be1999824a689bd52aa40061fc12c9ccdc3feab5ea718/dateparser-1.2.0-py2.py3-none-any.whl", hash = "sha256:0b21ad96534e562920a0083e97fd45fa959882d4162acc358705144520a35830", size = 294995 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.9"
|
||||
@@ -1321,6 +1338,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2024.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "308"
|
||||
@@ -1372,6 +1398,44 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2024.11.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/30/9a87ce8336b172cc232a0db89a3af97929d06c11ceaa19d97d84fa90a8f8/regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", size = 483781 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/e8/00008ad4ff4be8b1844786ba6636035f7ef926db5686e4c0f98093612add/regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", size = 288455 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/85/cebcc0aff603ea0a201667b203f13ba75d9fc8668fab917ac5b2de3967bc/regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", size = 284759 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2b/701a4b0585cb05472a4da28ee28fdfe155f3638f5e1ec92306d924e5faf0/regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", size = 794976 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/bf/fa87e563bf5fee75db8915f7352e1887b1249126a1be4813837f5dbec965/regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", size = 833077 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/56/7295e6bad94b047f4d0834e4779491b81216583c00c288252ef625c01d23/regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", size = 823160 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/13/e3b075031a738c9598c51cfbc4c7879e26729c53aa9cca59211c44235314/regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", size = 796896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/56/0b3f1b66d592be6efec23a795b37732682520b47c53da5a32c33ed7d84e3/regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", size = 783997 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/a1/eb378dada8b91c0e4c5f08ffb56f25fcae47bf52ad18f9b2f33b83e6d498/regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", size = 781725 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/f2/033e7dec0cfd6dda93390089864732a3409246ffe8b042e9554afa9bff4e/regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", size = 789481 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/23/15d4552ea28990a74e7696780c438aadd73a20318c47e527b47a4a5a596d/regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", size = 852896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/39/ed4416bc90deedbfdada2568b2cb0bc1fdb98efe11f5378d9892b2a88f8f/regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", size = 860138 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/2d/dd56bb76bd8e95bbce684326302f287455b56242a4f9c61f1bc76e28360e/regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", size = 787692 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/31877a249ab7a5156758246b9c59539abbeba22461b7d8adc9e8475ff73e/regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", size = 262135 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/ec/ad2d7de49a600cdb8dd78434a1aeffe28b9d6fc42eb36afab4a27ad23384/regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", size = 273567 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/73/bcb0e36614601016552fa9344544a3a2ae1809dc1401b100eab02e772e1f/regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", size = 483525 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/3f/f1a082a46b31e25291d830b369b6b0c5576a6f7fb89d3053a354c24b8a83/regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", size = 288324 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/c9/4e68181a4a652fb3ef5099e077faf4fd2a694ea6e0f806a7737aff9e758a/regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", size = 284617 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/fd/37868b75eaf63843165f1d2122ca6cb94bfc0271e4428cf58c0616786dce/regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", size = 795023 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/7c/d4cd9c528502a3dedb5c13c146e7a7a539a3853dc20209c8e75d9ba9d1b2/regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", size = 833072 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/db/46f563a08f969159c5a0f0e722260568425363bea43bb7ae370becb66a67/regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", size = 823130 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/60/1eeca2074f5b87df394fccaa432ae3fc06c9c9bfa97c5051aed70e6e00c2/regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", size = 796857 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/db/ac718a08fcee981554d2f7bb8402f1faa7e868c1345c16ab1ebec54b0d7b/regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", size = 784006 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/41/7da3fe70216cea93144bf12da2b87367590bcf07db97604edeea55dac9ad/regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", size = 781650 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/d5/880921ee4eec393a4752e6ab9f0fe28009435417c3102fc413f3fe81c4e5/regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", size = 789545 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/96/53770115e507081122beca8899ab7f5ae28ae790bfcc82b5e38976df6a77/regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", size = 853045 },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/d3/1372add5251cc2d44b451bd94f43b2ec78e15a6e82bff6a290ef9fd8f00a/regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", size = 860182 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
@@ -1570,6 +1634,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzlocal"
|
||||
version = "5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "platform_system == 'Windows'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/d3/c19d65ae67636fe63953b20c2e4a8ced4497ea232c43ff8d01db16de8dc0/tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e", size = 30201 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/3f/c4c51c55ff8487f2e6d0e618dba917e3c3ee2caae6cf0fbb59c9b1876f2e/tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", size = 17859 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.3.8"
|
||||
|
||||
Reference in New Issue
Block a user