fix tests for context and permalinks

This commit is contained in:
phernandez
2025-01-18 23:54:28 -06:00
parent 9af2f481b2
commit 32606d771d
41 changed files with 262 additions and 250 deletions
@@ -119,7 +119,7 @@ async def add_observations(
@router.get("/entities/{permalink:path}", response_model=EntityResponse)
async def get_entity(
entity_service: EntityServiceDep,
permalink: PathId,
permalink: str,
) -> EntityResponse:
"""Get a specific entity by ID.
@@ -36,6 +36,7 @@ async def get_memory_context(
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
@@ -46,7 +47,7 @@ async def get_memory_context(
since = parse_timeframe(timeframe)
# Build context
context = await context_service.build_context(memory_url, depth=depth, since=since)
context = await context_service.build_context(memory_url, depth=depth, since=since, max_results=max_results)
primary_entities = [SearchResult(**asdict(r)) for r in context["primary_entities"]]
related_entities = [RelatedResult(**asdict(r)) for r in context["related_entities"]]
+2 -1
View File
@@ -1,4 +1,6 @@
"""Base package for markdown parsing."""
from basic_memory.file_utils import ParseError
from basic_memory.markdown.entity_parser import EntityParser
from basic_memory.markdown.schemas import (
EntityMarkdown,
@@ -7,7 +9,6 @@ from basic_memory.markdown.schemas import (
Observation,
Relation,
)
from basic_memory.utils.file_utils import ParseError
__all__ = [
"EntityMarkdown",
+30
View File
@@ -1,9 +1,39 @@
"""Enhanced FastMCP server instance for Basic Memory."""
import sys
from loguru import logger
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.utilities.logging import configure_logging
from basic_memory.config import config
# mcp console logging
configure_logging(level="INFO")
def setup_logging(home_dir: str = config.home, log_file: str = ".basic-memory/basic-memory.log"):
"""Configure file logging to the basic-memory home directory."""
log = f"{home_dir}/{log_file}"
# Add file handler with rotation
logger.add(
log,
rotation="100 MB",
retention="10 days",
backtrace=True,
diagnose=True,
enqueue=True,
colorize=False,
)
# Add stderr handler
logger.add(
sys.stderr,
colorize=True,
)
# start our out file logging
setup_logging()
# Create the shared server instance
mcp = FastMCP("Basic Memory")
+1 -1
View File
@@ -13,7 +13,7 @@ from basic_memory.mcp.tools import search # noqa: F401
from basic_memory.mcp.tools.activity import (
get_recent_activity,
)
from basic_memory.mcp.tools.discussion import build_context
from basic_memory.mcp.tools.memory import build_context
from basic_memory.mcp.tools.ai_edit import ai_edit
# Export the tools
@@ -14,8 +14,9 @@ from basic_memory.schemas.memory import GraphContext, MemoryUrl
)
async def build_context(
url: MemoryUrl,
depth: Optional[int] = 2,
depth: Optional[int] = 1,
timeframe: Optional[str] = "7d",
max_results: int = 10
) -> GraphContext:
"""Get context needed to continue a discussion.
@@ -28,6 +29,7 @@ async def build_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)
Returns:
GraphContext containing:
@@ -39,6 +41,6 @@ async def build_context(
# Map directly to the memory endpoint
memory_url = MemoryUrl.validate(url)
response = await client.get(
f"/memory/{memory_url.relative_path()}", params={"depth": depth, "timeframe": timeframe}
f"/memory/{memory_url.relative_path()}", params={"depth": depth, "timeframe": timeframe, "max_results": max_results}
)
return GraphContext.model_validate(response.json())
+3 -5
View File
@@ -1,17 +1,15 @@
"""Search tools for Basic Memory MCP server."""
from mcp.server.fastmcp import Context
from loguru import logger
from basic_memory.mcp.server import mcp
from basic_memory.schemas.search import SearchQuery, SearchResponse
from basic_memory.schemas.request import GetEntitiesRequest
from basic_memory.schemas.response import EntityListResponse
from basic_memory.mcp.async_client import client
@mcp.tool(
description="Search across all content in basic-memory, including documents and entities",
)
async def search(ctx: Context, query: SearchQuery) -> SearchResponse:
async def search(query: SearchQuery) -> SearchResponse:
"""Search across all content in basic-memory.
Args:
@@ -24,6 +22,6 @@ async def search(ctx: Context, query: SearchQuery) -> SearchResponse:
Returns:
SearchResponse with search results and metadata
"""
ctx.info(f"Searching for {query.text}")
logger.info(f"Searching for {query.text}")
response = await client.post("/search/", json=query.model_dump())
return SearchResponse.model_validate(response.json())
-41
View File
@@ -23,47 +23,6 @@ from basic_memory.models.base import Base
from enum import Enum
def generate_permalink(file_path: str) -> str:
"""Generate a stable permalink from a file path.
Args:
file_path: Original file path
Returns:
Normalized permalink that matches validation rules. Converts spaces and underscores
to hyphens for consistency.
Examples:
>>> generate_permalink("docs/My Feature.md")
'docs/my-feature'
>>> generate_permalink("specs/API (v2).md")
'specs/api-v2'
>>> generate_permalink("design/unified_model_refactor.md")
'design/unified-model-refactor'
"""
# Remove extension
base = os.path.splitext(file_path)[0]
# Transliterate unicode to ascii
ascii_text = unidecode(base)
# Convert to lowercase
lower_text = ascii_text.lower()
# First replace underscores with hyphens
text_with_hyphens = lower_text.replace('_', '-')
# Replace remaining invalid chars with hyphens
clean_text = re.sub(r'[^a-z0-9/\-]', '-', text_with_hyphens)
# Collapse multiple hyphens
clean_text = re.sub(r'-+', '-', clean_text)
# Clean each path segment
segments = clean_text.split('/')
clean_segments = [s.strip('-') for s in segments]
return '/'.join(clean_segments)
class Entity(Base):
+4 -3
View File
@@ -19,6 +19,8 @@ from typing import List, Optional, Annotated, Dict
from annotated_types import MinLen, MaxLen
from pydantic import BaseModel, BeforeValidator, Field, model_validator, ValidationError
from basic_memory.utils import generate_permalink
def to_snake_case(name: str) -> str:
"""Convert a string to snake_case.
@@ -79,7 +81,7 @@ class ObservationCategory(str, Enum):
return None
PathId = Annotated[str, BeforeValidator(to_snake_case), BeforeValidator(validate_path_format)]
PathId = Annotated[str, BeforeValidator(validate_path_format)]
"""Unique identifier in format '{path}/{normalized_name}'."""
Observation = Annotated[
@@ -154,8 +156,7 @@ class Entity(BaseModel):
@property
def permalink(self) -> PathId:
"""Get the path ID in format {snake_case_title}."""
normalized_name = to_snake_case(self.title)
return normalized_name
return generate_permalink(self.title)
@property
def file_path(self):
+5 -5
View File
@@ -56,7 +56,7 @@ class RelationResponse(Relation, SQLAlchemyModel):
Example Response:
{
"from_id": "test/memory_test",
"to_id": "component/memory_service",
"to_id": "component/memory-service",
"relation_type": "validates",
"context": "Comprehensive test suite"
}
@@ -91,7 +91,7 @@ class EntityResponse(SQLAlchemyModel):
Example Response:
{
"permalink": "component/memory_service",
"permalink": "component/memory-service",
"title": "MemoryService",
"entity_type": "component",
"description": "Core persistence service",
@@ -109,8 +109,8 @@ class EntityResponse(SQLAlchemyModel):
],
"relations": [
{
"from_id": "test/memory_test",
"to_id": "component/memory_service",
"from_id": "test/memory-test",
"to_id": "component/memory-service",
"relation_type": "validates",
"context": "Main test suite"
}
@@ -181,7 +181,7 @@ class SearchNodesResponse(SQLAlchemyModel):
{
"matches": [
{
"permalink": "component/memory_service",
"permalink": "component/memory-service",
"title": "MemoryService",
"entity_type": "component",
"description": "Core service",
-1
View File
@@ -98,7 +98,6 @@ class RelatedResult(BaseModel):
relation_type: Optional[str] = None
category: Optional[str] = None
entity_id: Optional[int] = None
content: Optional[str] = None
class SearchResponse(BaseModel):
+4 -7
View File
@@ -27,7 +27,7 @@ class ContextResultRow:
relation_type: Optional[str] = None
category: Optional[str] = None
entity_id: Optional[int] = None
content: Optional[str] = None
class ContextService:
@@ -50,8 +50,9 @@ class ContextService:
async def build_context(
self,
memory_url: MemoryUrl,
depth: int = 2,
depth: int = 1,
since: Optional[datetime] = None,
max_results: int = 10
):
"""Build rich context from a memory:// URI."""
logger.debug(f"Building context for URI {memory_url}")
@@ -136,7 +137,6 @@ class ContextService:
relation_type,
category,
entity_id,
content,
0 as depth,
id as root_id,
created_at
@@ -157,7 +157,6 @@ class ContextService:
related.relation_type,
related.category,
related.entity_id,
related.content,
cg.depth + 1,
cg.root_id,
related.created_at
@@ -196,14 +195,13 @@ class ContextService:
relation_type,
category,
entity_id,
content,
MIN(depth) as depth,
root_id,
created_at
FROM context_graph
GROUP BY
type, id, title, permalink, from_id, to_id,
relation_type, category, entity_id, content,
relation_type, category, entity_id,
root_id, created_at
ORDER BY depth, type, id
""")
@@ -222,7 +220,6 @@ class ContextService:
relation_type=row.relation_type,
category=row.category,
entity_id=row.entity_id,
content=row.content,
depth=row.depth,
root_id=row.root_id,
created_at=row.created_at,
+1 -1
View File
@@ -5,9 +5,9 @@ from typing import Optional, Dict, Any, Tuple
from loguru import logger
from basic_memory import file_utils
from basic_memory.markdown.knowledge_writer import KnowledgeWriter
from basic_memory.services.exceptions import FileOperationError
from basic_memory.utils import file_utils
from basic_memory.models import Entity as EntityModel
+2 -6
View File
@@ -4,15 +4,13 @@ from typing import Optional, Tuple, List
from loguru import logger
from basic_memory.services.service import BaseService
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.services.search_service import SearchService
from basic_memory.models import Entity
from basic_memory.models.knowledge import generate_permalink
from basic_memory.schemas.search import SearchQuery, SearchResult, SearchItemType
class LinkResolver():
class LinkResolver:
"""Service for resolving markdown links to permalinks.
Uses a combination of exact matching and search-based resolution:
@@ -31,8 +29,7 @@ class LinkResolver():
self,
link_text: str,
) -> Entity:
"""Resolve a markdown link to a permalink.
"""
"""Resolve a markdown link to a permalink."""
logger.debug(f"Resolving link: {link_text}")
# Clean link text and extract any alias
@@ -60,7 +57,6 @@ class LinkResolver():
best_match = self._select_best_match(clean_text, results)
logger.debug(f"Selected best match from {len(results)} results: {best_match.permalink}")
return await self.entity_repository.get_by_permalink(best_match.permalink)
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
"""Normalize link text and extract alias if present.
+1 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError
from basic_memory.models import Entity as EntityModel, Observation, Relation, ObservationCategory
from basic_memory.markdown.schemas import EntityMarkdown
from basic_memory.models.knowledge import generate_permalink
from basic_memory.utils import generate_permalink
from basic_memory.repository import EntityRepository, ObservationRepository, RelationRepository
from basic_memory.services.exceptions import EntityNotFoundError
from basic_memory.services.link_resolver import LinkResolver
+2 -2
View File
@@ -6,10 +6,10 @@ from typing import Dict, Sequence
from loguru import logger
from basic_memory.file_utils import compute_checksum
from basic_memory.models import Entity
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.sync.utils import SyncReport
from basic_memory.utils.file_utils import compute_checksum
@dataclass
@@ -87,7 +87,7 @@ class FileChangeScanner:
return result
async def find_changes(
self, directory: Path, db_file_state: Dict[str, FileState]
self, directory: Path, db_file_state: Dict[str, FileState]
) -> SyncReport:
"""Find changes between filesystem and database."""
# Get current files and checksums
+49 -1
View File
@@ -1,8 +1,10 @@
"""Utility functions for basic-memory."""
import os
import re
import unicodedata
from unidecode import unidecode
def sanitize_name(name: str) -> str:
"""
@@ -27,3 +29,49 @@ def sanitize_name(name: str) -> str:
name = re.sub(r"_+", "_", name).strip("_")
return name
def generate_permalink(file_path: str) -> str:
"""Generate a stable permalink from a file path.
Args:
file_path: Original file path
Returns:
Normalized permalink that matches validation rules. Converts spaces and underscores
to hyphens for consistency.
Examples:
>>> generate_permalink("docs/My Feature.md")
'docs/my-feature'
>>> generate_permalink("specs/API (v2).md")
'specs/api-v2'
>>> generate_permalink("design/unified_model_refactor.md")
'design/unified-model-refactor'
"""
# Remove extension
base = os.path.splitext(file_path)[0]
# Transliterate unicode to ascii
ascii_text = unidecode(base)
# Insert dash between camelCase
ascii_text = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", ascii_text)
# Convert to lowercase
lower_text = ascii_text.lower()
# replace underscores with hyphens
text_with_hyphens = lower_text.replace('_', '-')
# Replace remaining invalid chars with hyphens
clean_text = re.sub(r'[^a-z0-9/\-]', '-', text_with_hyphens)
# Collapse multiple hyphens
clean_text = re.sub(r'-+', '-', clean_text)
# Clean each path segment
segments = clean_text.split('/')
clean_segments = [s.strip('-') for s in segments]
return '/'.join(clean_segments)
-1
View File
@@ -1 +0,0 @@
"""Utility functions and helpers."""