mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix tests for context and permalinks
This commit is contained in:
@@ -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"]]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
"""Utility functions and helpers."""
|
||||
@@ -20,7 +20,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="Core memory service",
|
||||
permalink="component/memory_service",
|
||||
permalink="component/memory-service",
|
||||
file_path="component/memory_service.md",
|
||||
observations=[
|
||||
Observation(category="tech", content="Using SQLite for storage"),
|
||||
@@ -32,7 +32,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="File format spec",
|
||||
permalink="spec/file_format",
|
||||
permalink="spec/file-format",
|
||||
file_path="spec/file_format.md",
|
||||
observations=[
|
||||
Observation(category="feature", content="Support for frontmatter"),
|
||||
@@ -44,7 +44,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="Architecture decision",
|
||||
permalink="decision/tech_choice",
|
||||
permalink="decision/tech-choice",
|
||||
file_path="decision/tech_choice.md",
|
||||
observations=[
|
||||
Observation(category="note", content="Team discussed options"),
|
||||
@@ -57,7 +57,7 @@ async def test_entities(entity_repository: EntityRepository) -> list[Entity]:
|
||||
entity_type="test",
|
||||
content_type="text/markdown",
|
||||
summary="API layer",
|
||||
permalink="component/api_service",
|
||||
permalink="component/api-service",
|
||||
file_path="component/api_service.md",
|
||||
observations=[
|
||||
Observation(category="tech", content="FastAPI based"),
|
||||
|
||||
@@ -69,8 +69,8 @@ async def create_related_entities(client) -> List[RelationResponse]: # pyright:
|
||||
create_response = await client.post("/knowledge/entities", json={"entities": entities})
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()["entities"]
|
||||
source_permalink = "source_entity"
|
||||
target_permalink = "target_entity"
|
||||
source_permalink = "source-entity"
|
||||
target_permalink = "target-entity"
|
||||
|
||||
# Create relation between them
|
||||
response = await client.post(
|
||||
@@ -133,7 +133,7 @@ async def test_get_entity(client: AsyncClient):
|
||||
entity = response.json()
|
||||
assert entity["title"] == "TestEntity"
|
||||
assert entity["entity_type"] == "test"
|
||||
assert entity["permalink"] == "test_entity"
|
||||
assert entity["permalink"] == "test-entity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -149,7 +149,7 @@ async def test_add_observations(client: AsyncClient):
|
||||
data = {"title": "TestEntity", "entity_type": "test"}
|
||||
response = await client.post("/knowledge/entities", json={"entities": [data]})
|
||||
|
||||
permalink = "test_entity"
|
||||
permalink = "test-entity"
|
||||
# Add observations
|
||||
await add_observations(client, permalink)
|
||||
|
||||
@@ -171,7 +171,7 @@ async def test_get_entities(client: AsyncClient):
|
||||
|
||||
# Open nodes by path IDs
|
||||
response = await client.get(
|
||||
"/knowledge/entities?permalink=alpha_test&permalink=beta_test",
|
||||
"/knowledge/entities?permalink=alpha-test&permalink=beta-test",
|
||||
)
|
||||
|
||||
# Verify results
|
||||
@@ -182,12 +182,12 @@ async def test_get_entities(client: AsyncClient):
|
||||
entity_0 = data["entities"][0]
|
||||
assert entity_0["title"] == "AlphaTest"
|
||||
assert entity_0["entity_type"] == "test"
|
||||
assert entity_0["permalink"] == "alpha_test"
|
||||
assert entity_0["permalink"] == "alpha-test"
|
||||
|
||||
entity_1 = data["entities"][1]
|
||||
assert entity_1["title"] == "BetaTest"
|
||||
assert entity_1["entity_type"] == "test"
|
||||
assert entity_1["permalink"] == "beta_test"
|
||||
assert entity_1["permalink"] == "beta-test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -240,10 +240,10 @@ async def test_delete_entity_with_observations(client, observation_repository):
|
||||
# Create test entity and add observations
|
||||
entity_data = {"title": "TestEntity", "entity_type": "test"}
|
||||
await client.post("/knowledge/entities", json={"entities": [entity_data]})
|
||||
await add_observations(client, "TestEntity")
|
||||
await add_observations(client, "test-entity")
|
||||
|
||||
# Delete entity
|
||||
response = await client.post("/knowledge/entities/delete", json={"permalinks": ["TestEntity"]})
|
||||
response = await client.post("/knowledge/entities/delete", json={"permalinks": ["test-entity"]})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"deleted": True}
|
||||
|
||||
@@ -258,10 +258,10 @@ async def test_delete_observations(client, observation_repository):
|
||||
# Create entity and add observations
|
||||
entity_data = {"title": "TestEntity", "entity_type": "test"}
|
||||
await client.post("/knowledge/entities", json={"entities": [entity_data]})
|
||||
observations = await add_observations(client, "TestEntity") # adds 2
|
||||
observations = await add_observations(client, "test-entity") # adds 2
|
||||
|
||||
# Delete specific observations
|
||||
request_data = {"permalink": "TestEntity", "observations": [observations[0].content]}
|
||||
request_data = {"permalink": "test-entity", "observations": [observations[0].content]}
|
||||
response = await client.post("/knowledge/observations/delete", json=request_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -314,7 +314,7 @@ async def test_delete_nonexistent_observations(client: AsyncClient):
|
||||
entity_data = {"title": "TestEntity", "entity_type": "test"}
|
||||
await client.post("/knowledge/entities", json={"entities": [entity_data]})
|
||||
|
||||
request_data = {"permalink": "TestEntity", "observations": ["Nonexistent observation"]}
|
||||
request_data = {"permalink": "test-entity", "observations": ["Nonexistent observation"]}
|
||||
response = await client.post("/knowledge/observations/delete", json=request_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -366,13 +366,13 @@ async def test_full_knowledge_flow(client: AsyncClient):
|
||||
json={
|
||||
"relations": [
|
||||
{
|
||||
"from_id": "main_entity",
|
||||
"to_id": "related_one",
|
||||
"from_id": "main-entity",
|
||||
"to_id": "related-one",
|
||||
"relation_type": "connects_to",
|
||||
},
|
||||
{
|
||||
"from_id": "main_entity",
|
||||
"to_id": "related_two",
|
||||
"from_id": "main-entity",
|
||||
"to_id": "related-two",
|
||||
"relation_type": "connects_to",
|
||||
},
|
||||
]
|
||||
@@ -386,7 +386,7 @@ async def test_full_knowledge_flow(client: AsyncClient):
|
||||
await client.post(
|
||||
"/knowledge/observations",
|
||||
json={
|
||||
"permalink": "main_entity",
|
||||
"permalink": "main-entity",
|
||||
"observations": [
|
||||
{"content": "Connected to first related entity", "category": "tech"},
|
||||
{"content": "Connected to second related entity", "category": "note"},
|
||||
@@ -396,7 +396,7 @@ async def test_full_knowledge_flow(client: AsyncClient):
|
||||
)
|
||||
|
||||
# 5. Verify full graph structure
|
||||
permalink = "MainEntity"
|
||||
permalink = "main-entity"
|
||||
main_get = await client.get(f"/knowledge/entities/{permalink}")
|
||||
main_entity = main_get.json()
|
||||
|
||||
@@ -412,13 +412,13 @@ async def test_full_knowledge_flow(client: AsyncClient):
|
||||
|
||||
# 7. Delete main entity
|
||||
response = await client.post(
|
||||
"/knowledge/entities/delete", json={"permalinks": ["MainEntity", "NonEntity"]}
|
||||
"/knowledge/entities/delete", json={"permalinks": ["main-entity", "non-entity"]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"deleted": True}
|
||||
|
||||
# Verify deletion
|
||||
permalink = "MainEntity"
|
||||
permalink = "main-entity"
|
||||
response = await client.get(f"/knowledge/entities/{permalink}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -443,7 +443,7 @@ async def test_entity_indexing(client: AsyncClient):
|
||||
assert search_response.status_code == 200
|
||||
search_result = SearchResponse.model_validate(search_response.json())
|
||||
assert len(search_result.results) == 1
|
||||
assert search_result.results[0].permalink == "search_test"
|
||||
assert search_result.results[0].permalink == "search-test"
|
||||
assert search_result.results[0].type == SearchItemType.ENTITY.value
|
||||
|
||||
|
||||
@@ -513,6 +513,7 @@ async def test_entity_delete_indexing(client: AsyncClient):
|
||||
search_result = SearchResponse.model_validate(search_response.json())
|
||||
assert len(search_result.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.skip("relation info is not indexed yet")
|
||||
@pytest.mark.asyncio
|
||||
async def test_relation_indexing(client: AsyncClient):
|
||||
@@ -579,7 +580,6 @@ async def test_update_entity_basic(client: AsyncClient):
|
||||
assert updated["entity_metadata"]["status"] == "draft" # Preserved
|
||||
|
||||
|
||||
|
||||
@pytest.mark.skip("Skip until we can request content")
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_entity_content(client: AsyncClient):
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# from basic_memory.cli.commands.status import display_changes, run_status
|
||||
# from basic_memory.sync.file_change_scanner import FileState
|
||||
# from basic_memory.sync.utils import SyncReport
|
||||
# from basic_memory.utils.file_utils import compute_checksum
|
||||
# from basic_memory.file_utils import compute_checksum
|
||||
#
|
||||
#
|
||||
# @pytest.fixture
|
||||
|
||||
+15
-10
@@ -171,7 +171,9 @@ async def entity_sync_service(
|
||||
link_resolver: LinkResolver,
|
||||
) -> EntitySyncService:
|
||||
"""Create EntitySyncService with repository."""
|
||||
return EntitySyncService(entity_repository, observation_repository, relation_repository, link_resolver)
|
||||
return EntitySyncService(
|
||||
entity_repository, observation_repository, relation_repository, link_resolver
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -221,7 +223,7 @@ async def sample_entity(entity_repository: EntityRepository) -> Entity:
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"summary": "A test entity",
|
||||
"permalink": "test/test_entity",
|
||||
"permalink": "test/test-entity",
|
||||
"file_path": "test/test_entity.md",
|
||||
"content_type": "text/markdown",
|
||||
}
|
||||
@@ -232,14 +234,16 @@ async def sample_entity(entity_repository: EntityRepository) -> Entity:
|
||||
async def full_entity(sample_entity, entity_repository):
|
||||
"""Create a search test entity."""
|
||||
|
||||
search_entity = await entity_repository.create({
|
||||
"title": "Search Entity",
|
||||
"entity_type": "test",
|
||||
"summary": "A searchable entity",
|
||||
"permalink": "test/search_entity",
|
||||
"file_path": "test/search_entity.md",
|
||||
"content_type": "text/markdown",
|
||||
})
|
||||
search_entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Search Entity",
|
||||
"entity_type": "test",
|
||||
"summary": "A searchable entity",
|
||||
"permalink": "test/search-entity",
|
||||
"file_path": "test/search_entity.md",
|
||||
"content_type": "text/markdown",
|
||||
}
|
||||
)
|
||||
|
||||
observations = [
|
||||
Observation(content="Tech note", category=ObservationCategory.TECH),
|
||||
@@ -253,6 +257,7 @@ async def full_entity(sample_entity, entity_repository):
|
||||
search_entity.outgoing_relations = relations
|
||||
return await entity_repository.add(search_entity)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_graph(entity_repository, search_service):
|
||||
"""Create a test knowledge graph with entities, relations and observations."""
|
||||
|
||||
@@ -20,7 +20,7 @@ def sample_entity() -> Entity:
|
||||
id=1,
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
permalink="knowledge/test_entity",
|
||||
permalink="knowledge/test-entity",
|
||||
file_path="knowledge/test_entity.md",
|
||||
summary="Test description",
|
||||
created_at=datetime(2025, 1, 1, tzinfo=UTC),
|
||||
@@ -44,7 +44,7 @@ def entity_with_observations(sample_entity: Entity) -> Entity:
|
||||
def entity_with_relations(sample_entity: Entity) -> Entity:
|
||||
"""Create an entity with relations."""
|
||||
target = Entity(
|
||||
id=2, title="target_entity", entity_type="test", permalink="knowledge/target_entity"
|
||||
id=2, title="target_entity", entity_type="test", permalink="knowledge/target-entity"
|
||||
)
|
||||
sample_entity.outgoing_relations = [
|
||||
Relation(from_id=1, to_id=2, relation_type="connects_to", to_entity=target)
|
||||
@@ -57,7 +57,7 @@ async def test_format_frontmatter_basic(knowledge_writer: KnowledgeWriter, sampl
|
||||
"""Test basic frontmatter formatting."""
|
||||
frontmatter = await knowledge_writer.format_frontmatter(sample_entity)
|
||||
|
||||
assert frontmatter["id"] == "knowledge/test_entity"
|
||||
assert frontmatter["id"] == "knowledge/test-entity"
|
||||
assert frontmatter["type"] == "test"
|
||||
assert frontmatter["created"] == "2025-01-01T00:00:00+00:00"
|
||||
assert frontmatter["modified"] == "2025-01-02T00:00:00+00:00"
|
||||
@@ -74,7 +74,7 @@ async def test_format_frontmatter_with_metadata(
|
||||
|
||||
assert frontmatter["status"] == "active"
|
||||
assert frontmatter["priority"] == "high"
|
||||
assert frontmatter["id"] == "knowledge/test_entity"
|
||||
assert frontmatter["id"] == "knowledge/test-entity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -4,10 +4,8 @@ from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.utils.file_utils import FileError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -34,22 +32,22 @@ async def test_unicode_content(tmp_path):
|
||||
- tested_by [[测试组件]] (Unicode test)
|
||||
- depends_on [[компонент]] (Another test)
|
||||
""")
|
||||
|
||||
|
||||
test_file = tmp_path / "unicode.md"
|
||||
test_file.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
parser = EntityParser(tmp_path)
|
||||
entity = await parser.parse_file(test_file)
|
||||
|
||||
|
||||
assert "测试" in entity.frontmatter.tags
|
||||
assert "chinese" not in entity.frontmatter.tags
|
||||
assert "🧪" in entity.content.content
|
||||
|
||||
|
||||
# Verify Unicode in observations
|
||||
assert any(o.content == "Emoji test 👍" for o in entity.content.observations)
|
||||
assert any(o.category == "中文" for o in entity.content.observations)
|
||||
assert any(o.category == "русский" for o in entity.content.observations)
|
||||
|
||||
|
||||
# Verify Unicode in relations
|
||||
assert any(r.target == "测试组件" for r in entity.content.relations)
|
||||
assert any(r.target == "компонент" for r in entity.content.relations)
|
||||
@@ -60,7 +58,7 @@ async def test_empty_file(tmp_path):
|
||||
"""Test handling of empty files."""
|
||||
empty_file = tmp_path / "empty.md"
|
||||
empty_file.write_text("")
|
||||
|
||||
|
||||
parser = EntityParser(tmp_path)
|
||||
entity = await parser.parse_file(empty_file)
|
||||
assert entity.content.observations == []
|
||||
@@ -82,10 +80,10 @@ async def test_missing_sections(tmp_path):
|
||||
Just some content
|
||||
with [[links]] but no sections
|
||||
""")
|
||||
|
||||
|
||||
test_file = tmp_path / "missing.md"
|
||||
test_file.write_text(content)
|
||||
|
||||
|
||||
parser = EntityParser(tmp_path)
|
||||
entity = await parser.parse_file(test_file)
|
||||
assert len(entity.content.relations) == 1
|
||||
@@ -145,13 +143,13 @@ async def test_nested_content(tmp_path):
|
||||
- [test] Level 3 #test (Third level)
|
||||
- needs [[Three]]
|
||||
""")
|
||||
|
||||
|
||||
test_file = tmp_path / "nested.md"
|
||||
test_file.write_text(content)
|
||||
|
||||
|
||||
parser = EntityParser(tmp_path)
|
||||
entity = await parser.parse_file(test_file)
|
||||
|
||||
|
||||
# Should find all observations and relations regardless of nesting
|
||||
assert len(entity.content.observations) == 3
|
||||
assert len(entity.content.relations) == 3
|
||||
@@ -169,14 +167,14 @@ async def test_malformed_frontmatter(tmp_path):
|
||||
|
||||
# Test
|
||||
""")
|
||||
|
||||
|
||||
test_file = tmp_path / "malformed.md"
|
||||
test_file.write_text(content)
|
||||
|
||||
|
||||
parser = EntityParser(tmp_path)
|
||||
entity = await parser.parse_file(test_file)
|
||||
assert entity.frontmatter.permalink is None
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_not_found():
|
||||
|
||||
@@ -30,7 +30,7 @@ async def test_create_basic_entity(client):
|
||||
entity = result.entities[0]
|
||||
assert entity.title == "TestEntity"
|
||||
assert entity.entity_type == "test"
|
||||
assert entity.permalink == "test_entity"
|
||||
assert entity.permalink == "test-entity"
|
||||
assert entity.summary == "A test entity"
|
||||
|
||||
# Check observations
|
||||
@@ -122,7 +122,7 @@ async def test_create_minimal_entity(client):
|
||||
entity = result.entities[0]
|
||||
assert entity.title == "MinimalEntity"
|
||||
assert entity.entity_type == "test"
|
||||
assert entity.permalink == "minimal_entity"
|
||||
assert entity.permalink == "minimal-entity"
|
||||
assert entity.summary is None
|
||||
assert len(entity.observations) == 0
|
||||
assert len(entity.relations) == 0
|
||||
|
||||
@@ -22,7 +22,7 @@ async def test_create_basic_relation(client):
|
||||
# Create relation between them
|
||||
relation_request = CreateRelationsRequest(
|
||||
relations=[
|
||||
Relation(from_id="source_entity", to_id="target_entity", relation_type="depends_on")
|
||||
Relation(from_id="source-entity", to_id="target-entity", relation_type="depends_on")
|
||||
]
|
||||
)
|
||||
result = await create_relations(relation_request)
|
||||
@@ -30,8 +30,8 @@ async def test_create_basic_relation(client):
|
||||
assert len(result.entities) == 2
|
||||
|
||||
# Find source and target entities
|
||||
source = next(e for e in result.entities if e.permalink == "source_entity")
|
||||
target = next(e for e in result.entities if e.permalink == "target_entity")
|
||||
source = next(e for e in result.entities if e.permalink == "source-entity")
|
||||
target = next(e for e in result.entities if e.permalink == "target-entity")
|
||||
|
||||
# Both entities should have the relation for bi-directional navigation
|
||||
assert len(source.relations) == 1
|
||||
@@ -39,14 +39,14 @@ async def test_create_basic_relation(client):
|
||||
|
||||
# Source's relation shows it depends_on target
|
||||
source_relation = source.relations[0]
|
||||
assert source_relation.from_id == "source_entity"
|
||||
assert source_relation.to_id == "target_entity"
|
||||
assert source_relation.from_id == "source-entity"
|
||||
assert source_relation.to_id == "target-entity"
|
||||
assert source_relation.relation_type == "depends_on"
|
||||
|
||||
# Target's relation is the same, allowing backwards traversal
|
||||
target_relation = target.relations[0]
|
||||
assert target_relation.from_id == "source_entity"
|
||||
assert target_relation.to_id == "target_entity"
|
||||
assert target_relation.from_id == "source-entity"
|
||||
assert target_relation.to_id == "target-entity"
|
||||
assert target_relation.relation_type == "depends_on"
|
||||
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ async def test_get_basic_entity(client):
|
||||
# Verify entity details
|
||||
assert entity.title == "TestEntity"
|
||||
assert entity.entity_type == "test"
|
||||
assert entity.permalink == "test_entity"
|
||||
assert entity.permalink == "test-entity"
|
||||
assert entity.summary == "A test entity"
|
||||
|
||||
# Check observations
|
||||
@@ -61,16 +61,16 @@ async def test_get_entity_with_relations(client):
|
||||
|
||||
relation_request = CreateRelationsRequest(
|
||||
relations=[
|
||||
Relation(from_id="source_entity", to_id="target_entity", relation_type="depends_on")
|
||||
Relation(from_id="source-entity", to_id="target-entity", relation_type="depends_on")
|
||||
]
|
||||
)
|
||||
await create_relations(relation_request)
|
||||
|
||||
# Get and verify source entity without content
|
||||
source = await get_entity("source_entity")
|
||||
source = await get_entity("source-entity")
|
||||
assert len(source.relations) == 1
|
||||
relation = source.relations[0]
|
||||
assert relation.to_id == "target_entity"
|
||||
assert relation.to_id == "target-entity"
|
||||
assert relation.relation_type == "depends_on"
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for discussion context MCP tool."""
|
||||
|
||||
import pytest
|
||||
from basic_memory.mcp.tools.discussion import build_context
|
||||
from basic_memory.mcp.tools.memory import build_context
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -18,7 +18,7 @@ async def test_get_basic_discussion_context(client, test_graph):
|
||||
|
||||
# Verify metadata
|
||||
assert context.metadata["uri"] == "test/root"
|
||||
assert context.metadata["depth"] == 2 # default depth
|
||||
assert context.metadata["depth"] == 1 # default depth
|
||||
assert context.metadata["timeframe"] is not None
|
||||
assert isinstance(context.metadata["generated_at"], str)
|
||||
assert context.metadata["matched_entities"] == 1
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import select
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.models import Entity, Observation, Relation
|
||||
from basic_memory.models.knowledge import generate_permalink
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ async def test_create_all(entity_repository: EntityRepository):
|
||||
{
|
||||
"title": "Test_1",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test_1",
|
||||
"permalink": "test/test-1",
|
||||
"file_path": "test/test_1.md",
|
||||
"summary": "Test description",
|
||||
"content_type": "text/markdown",
|
||||
@@ -102,7 +102,7 @@ async def test_create_all(entity_repository: EntityRepository):
|
||||
{
|
||||
"title": "Test-2",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test_2",
|
||||
"permalink": "test/test-2",
|
||||
"file_path": "test/test_2.md",
|
||||
"summary": "Test description",
|
||||
"content_type": "text/markdown",
|
||||
@@ -471,7 +471,7 @@ async def test_generate_permalink_from_file_path():
|
||||
("specs/API (v2).md", "specs/api-v2"),
|
||||
("notes/2024/Q1 Planning!!!.md", "notes/2024/q1-planning"),
|
||||
("test/Über File.md", "test/uber-file"),
|
||||
("docs/my_feature_name.md", "docs/my_feature_name"),
|
||||
("docs/my_feature_name.md", "docs/my-feature-name"),
|
||||
("specs/multiple--dashes.md", "specs/multiple-dashes"),
|
||||
("notes/trailing/space/ file.md", "notes/trailing/space/file"),
|
||||
]
|
||||
|
||||
@@ -91,7 +91,7 @@ async def test_delete_observations(session_maker: async_sessionmaker, repo):
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test_entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
@@ -121,7 +121,7 @@ async def test_delete_observation_by_id(session_maker: async_sessionmaker, repo)
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test_entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
@@ -150,7 +150,7 @@ async def test_delete_observation_by_content(session_maker: async_sessionmaker,
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test_entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
@@ -181,7 +181,7 @@ async def test_find_by_category(session_maker: async_sessionmaker, repo):
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test_entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
@@ -223,7 +223,7 @@ async def test_observation_categories(session_maker: async_sessionmaker, repo):
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test_entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
@@ -271,7 +271,7 @@ async def test_find_by_category_case_sensitivity(session_maker: async_sessionmak
|
||||
title="test_entity",
|
||||
entity_type="test",
|
||||
summary="Test entity",
|
||||
permalink="test/test_entity",
|
||||
permalink="test/test-entity",
|
||||
file_path="test/test_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ async def source_entity(session_maker):
|
||||
entity = Entity(
|
||||
title="test_source",
|
||||
entity_type="test",
|
||||
permalink="source/test_source",
|
||||
permalink="source/test-source",
|
||||
file_path="source/test_source.md",
|
||||
summary="Source entity",
|
||||
content_type="text/markdown",
|
||||
@@ -32,7 +32,7 @@ async def target_entity(session_maker):
|
||||
entity = Entity(
|
||||
title="test_target",
|
||||
entity_type="test",
|
||||
permalink="target/test_target",
|
||||
permalink="target/test-target",
|
||||
file_path="target/test_target.md",
|
||||
summary="Target entity",
|
||||
content_type="text/markdown",
|
||||
@@ -62,7 +62,7 @@ async def related_entity(entity_repository):
|
||||
entity_data = {
|
||||
"title": "Related Entity",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/related_entity",
|
||||
"permalink": "test/related-entity",
|
||||
"file_path": "test/related_entity.md",
|
||||
"summary": "A related test entity",
|
||||
"content_type": "text/markdown",
|
||||
|
||||
@@ -201,10 +201,10 @@ def test_path_sanitization():
|
||||
def test_permalink_generation():
|
||||
"""Test permalink property generates correct paths."""
|
||||
test_cases = [
|
||||
({"title": "BasicMemory", "entity_type": "knowledge"}, "basic_memory"),
|
||||
({"title": "Memory Service", "entity_type": "knowledge"}, "memory_service"),
|
||||
({"title": "API Gateway", "entity_type": "knowledge"}, "api_gateway"),
|
||||
({"title": "TestCase1", "entity_type": "knowledge"}, "test_case1"),
|
||||
({"title": "BasicMemory", "entity_type": "knowledge"}, "basic-memory"),
|
||||
({"title": "Memory Service", "entity_type": "knowledge"}, "memory-service"),
|
||||
({"title": "API Gateway", "entity_type": "knowledge"}, "api-gateway"),
|
||||
({"title": "TestCase1", "entity_type": "knowledge"}, "test-case1"),
|
||||
]
|
||||
|
||||
for input_data, expected_path in test_cases:
|
||||
|
||||
@@ -40,7 +40,7 @@ async def test_find_connected_basic(context_service, test_graph, search_service)
|
||||
assert test_graph["connected2"].id in entity_ids
|
||||
|
||||
# Verify we found observations
|
||||
assert any(r.type == "observation" and "Root note 1" in r.content for r in results)
|
||||
assert any(r.type == "observation" and "Root" in r.title for r in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -149,10 +149,10 @@ async def test_build_context(context_service, test_graph):
|
||||
total_entities = results["metadata"]["total_entities"]
|
||||
|
||||
assert results["metadata"]["uri"] == url.relative_path()
|
||||
assert results["metadata"]["depth"] == 2
|
||||
assert results["metadata"]["depth"] == 1
|
||||
assert matched_entities == 1
|
||||
assert len(primary_entities) == 1
|
||||
assert len(related_entities) == 10
|
||||
assert len(related_entities) == 8
|
||||
assert total_entities == len(primary_entities) + len(related_entities)
|
||||
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ async def test_write_atomic(tmp_path: Path, file_service: FileService):
|
||||
temp_path = test_path.with_suffix(".tmp")
|
||||
|
||||
# Mock write_file_atomic to raise an error
|
||||
with patch("basic_memory.utils.file_utils.write_file_atomic") as mock_write:
|
||||
with patch("basic_memory.file_utils.write_file_atomic") as mock_write:
|
||||
mock_write.side_effect = Exception("Write failed")
|
||||
|
||||
# Attempt write that will fail
|
||||
@@ -195,7 +195,7 @@ async def test_frontmatter_invalid_metadata(file_service: FileService):
|
||||
bad_metadata = {"bad": NonSerializable()}
|
||||
|
||||
# Attempting to add frontmatter with non-serializable content
|
||||
with patch("basic_memory.utils.file_utils.add_frontmatter") as mock_add:
|
||||
with patch("basic_memory.file_utils.add_frontmatter") as mock_add:
|
||||
mock_add.side_effect = FileOperationError("Failed to serialize metadata")
|
||||
with pytest.raises(FileOperationError):
|
||||
await file_service.add_frontmatter(frontmatter=frontmatter, content="content", metadata=bad_metadata)
|
||||
|
||||
@@ -19,7 +19,7 @@ async def test_entities(
|
||||
entity1 = EntityModel(
|
||||
title="test_entity_1",
|
||||
entity_type="test",
|
||||
permalink="test/test_entity_1",
|
||||
permalink="test/test-entity-1",
|
||||
file_path="test/test_entity_1.md",
|
||||
summary="Test entity 1",
|
||||
content_type="text/markdown",
|
||||
@@ -27,7 +27,7 @@ async def test_entities(
|
||||
entity2 = EntityModel(
|
||||
title="test_entity_2",
|
||||
entity_type="test",
|
||||
permalink="test/test_entity_2",
|
||||
permalink="test/test-entity-2",
|
||||
file_path="test/test_entity_2.md",
|
||||
summary="Test entity 2",
|
||||
content_type="text/markdown",
|
||||
|
||||
@@ -61,7 +61,7 @@ async def test_text_search_features(search_service, test_graph):
|
||||
assert any(r.file_path == "test/connected2.md" for r in results)
|
||||
|
||||
# Multiple terms
|
||||
results = await search_service.search(SearchQuery(text="root connected"))
|
||||
results = await search_service.search(SearchQuery(text="root note"))
|
||||
assert any("test/root" in r.permalink for r in results)
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_frontmatter() -> EntityFrontmatter:
|
||||
return EntityFrontmatter(
|
||||
title="Test Entity",
|
||||
type="knowledge",
|
||||
permalink="concept/test_entity",
|
||||
permalink="concept/test-entity",
|
||||
created=datetime.now(),
|
||||
modified=datetime.now(),
|
||||
tags=["test", "sync"],
|
||||
@@ -39,8 +39,8 @@ def test_content() -> EntityContent:
|
||||
MarkdownObservation(content="Second observation"),
|
||||
],
|
||||
relations=[
|
||||
MarkdownRelation(type="depends_on", target="concept/other_entity"),
|
||||
MarkdownRelation(type="related_to", target="concept/another_entity"),
|
||||
MarkdownRelation(type="depends_on", target="concept/other-entity"),
|
||||
MarkdownRelation(type="related_to", target="concept/another-entity"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_create_entity_without_relations(
|
||||
# Check basic fields
|
||||
assert entity.title == "Test Entity"
|
||||
assert entity.entity_type == "knowledge"
|
||||
assert entity.permalink == "concept/test_entity"
|
||||
assert entity.permalink == "concept/test-entity"
|
||||
assert entity.summary == "A test entity description"
|
||||
|
||||
# Check observations
|
||||
@@ -117,14 +117,14 @@ async def test_update_entity_relations(
|
||||
other_entity = EntityModel(
|
||||
title="Other Entity",
|
||||
entity_type="test",
|
||||
permalink="concept/other_entity",
|
||||
permalink="concept/other-entity",
|
||||
file_path="concept/other_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
another_entity = EntityModel(
|
||||
title="Another Entity",
|
||||
entity_type="test",
|
||||
permalink="concept/another_entity",
|
||||
permalink="concept/another-entity",
|
||||
file_path="concept/another_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
@@ -159,14 +159,14 @@ async def test_two_pass_sync_flow(
|
||||
other_entity = EntityModel(
|
||||
title="Other Entity",
|
||||
entity_type="test",
|
||||
permalink="concept/other_entity",
|
||||
permalink="concept/other-entity",
|
||||
file_path="concept/other_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
another_entity = EntityModel(
|
||||
title="Another Entity",
|
||||
entity_type="test",
|
||||
permalink="concept/another_entity",
|
||||
permalink="concept/another-entity",
|
||||
file_path="concept/another_entity.md",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.file_utils import compute_checksum
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.sync import FileChangeScanner
|
||||
from basic_memory.sync.file_change_scanner import FileState
|
||||
from basic_memory.utils.file_utils import compute_checksum
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -158,11 +158,7 @@ async def test_detect_moved_file(file_change_scanner: FileChangeScanner, temp_di
|
||||
|
||||
# Set up DB state with original location
|
||||
db_records = {
|
||||
old_path: FileState(
|
||||
file_path=old_path,
|
||||
permalink="test",
|
||||
checksum=original_checksum
|
||||
)
|
||||
old_path: FileState(file_path=old_path, permalink="test", checksum=original_checksum)
|
||||
}
|
||||
|
||||
# Move file to new location
|
||||
@@ -172,10 +168,7 @@ async def test_detect_moved_file(file_change_scanner: FileChangeScanner, temp_di
|
||||
old_file.rename(new_file)
|
||||
|
||||
# Check changes
|
||||
changes = await file_change_scanner.find_changes(
|
||||
directory=temp_dir,
|
||||
db_file_state=db_records
|
||||
)
|
||||
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
|
||||
|
||||
# Should detect as move
|
||||
assert len(changes.moves) == 1
|
||||
@@ -199,11 +192,7 @@ async def test_move_with_content_change(file_change_scanner: FileChangeScanner,
|
||||
|
||||
# Set up DB state with original location
|
||||
db_records = {
|
||||
old_path: FileState(
|
||||
file_path=old_path,
|
||||
permalink="test",
|
||||
checksum=original_checksum
|
||||
)
|
||||
old_path: FileState(file_path=old_path, permalink="test", checksum=original_checksum)
|
||||
}
|
||||
|
||||
# Move file and change content
|
||||
@@ -214,10 +203,7 @@ async def test_move_with_content_change(file_change_scanner: FileChangeScanner,
|
||||
old_file.unlink()
|
||||
|
||||
# Check changes
|
||||
changes = await file_change_scanner.find_changes(
|
||||
directory=temp_dir,
|
||||
db_file_state=db_records
|
||||
)
|
||||
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
|
||||
|
||||
# Should be treated as delete + new, not move
|
||||
assert old_path in changes.deleted
|
||||
@@ -229,14 +215,8 @@ async def test_move_with_content_change(file_change_scanner: FileChangeScanner,
|
||||
async def test_multiple_moves(file_change_scanner: FileChangeScanner, temp_dir: Path):
|
||||
"""Test detecting multiple file moves at once."""
|
||||
# Create original files
|
||||
files = {
|
||||
"a/test1.md": "content1",
|
||||
"b/test2.md": "content2"
|
||||
}
|
||||
new_locations = {
|
||||
"a/test1.md": "new/test1.md",
|
||||
"b/test2.md": "new/nested/test2.md"
|
||||
}
|
||||
files = {"a/test1.md": "content1", "b/test2.md": "content2"}
|
||||
new_locations = {"a/test1.md": "new/test1.md", "b/test2.md": "new/nested/test2.md"}
|
||||
|
||||
db_records = {}
|
||||
# Create files and DB state
|
||||
@@ -244,9 +224,7 @@ async def test_multiple_moves(file_change_scanner: FileChangeScanner, temp_dir:
|
||||
await create_test_file(temp_dir / old_path, content)
|
||||
checksum = await compute_checksum(content)
|
||||
db_records[old_path] = FileState(
|
||||
file_path=old_path,
|
||||
permalink=old_path.replace(".md", ""),
|
||||
checksum=checksum
|
||||
file_path=old_path, permalink=old_path.replace(".md", ""), checksum=checksum
|
||||
)
|
||||
|
||||
# Move all files
|
||||
@@ -257,14 +235,11 @@ async def test_multiple_moves(file_change_scanner: FileChangeScanner, temp_dir:
|
||||
old_file.rename(new_file)
|
||||
|
||||
# Check changes
|
||||
changes = await file_change_scanner.find_changes(
|
||||
directory=temp_dir,
|
||||
db_file_state=db_records
|
||||
)
|
||||
changes = await file_change_scanner.find_changes(directory=temp_dir, db_file_state=db_records)
|
||||
|
||||
# Should detect both moves
|
||||
assert len(changes.moves) == 2
|
||||
assert changes.moves["a/test1.md"] == "new/test1.md"
|
||||
assert changes.moves["b/test2.md"] == "new/nested/test2.md"
|
||||
assert not changes.new
|
||||
assert not changes.deleted
|
||||
assert not changes.deleted
|
||||
|
||||
@@ -29,7 +29,7 @@ async def test_sync_knowledge(
|
||||
new_content = """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/test_concept
|
||||
permalink: concept/test-concept
|
||||
created: 2023-01-01
|
||||
modified: 2023-01-01
|
||||
---
|
||||
@@ -65,7 +65,7 @@ A test concept.
|
||||
assert len(entities) == 1
|
||||
|
||||
# Find new entity
|
||||
test_concept = next(e for e in entities if e.permalink == "concept/test_concept")
|
||||
test_concept = next(e for e in entities if e.permalink == "concept/test-concept")
|
||||
assert test_concept.entity_type == "knowledge"
|
||||
|
||||
# Verify relation was not created
|
||||
@@ -86,7 +86,7 @@ async def test_sync_entity_with_nonexistent_relations(
|
||||
content = """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/depends_on_future
|
||||
permalink: concept/depends-on-future
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -106,7 +106,7 @@ modified: 2024-01-01
|
||||
|
||||
# Verify entity created but no relations
|
||||
entity = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/depends_on_future"
|
||||
"concept/depends-on-future"
|
||||
)
|
||||
assert entity is not None
|
||||
assert len(entity.relations) == 0 # Relations to nonexistent entities should be skipped
|
||||
@@ -123,7 +123,7 @@ async def test_sync_entity_circular_relations(
|
||||
content_a = """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/entity_a
|
||||
permalink: concept/entity-a
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -133,7 +133,7 @@ modified: 2024-01-01
|
||||
- First entity in circular reference
|
||||
|
||||
## Relations
|
||||
- depends_on [[concept/entity_b]]
|
||||
- depends_on [[concept/entity-b]]
|
||||
"""
|
||||
await create_test_file(project_dir / "concept/entity_a.md", content_a)
|
||||
|
||||
@@ -141,7 +141,7 @@ modified: 2024-01-01
|
||||
content_b = """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/entity_b
|
||||
permalink: concept/entity-b
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -151,7 +151,7 @@ modified: 2024-01-01
|
||||
- Second entity in circular reference
|
||||
|
||||
## Relations
|
||||
- depends_on [[concept/entity_a]]
|
||||
- depends_on [[concept/entity-a]]
|
||||
"""
|
||||
await create_test_file(project_dir / "concept/entity_b.md", content_b)
|
||||
|
||||
@@ -160,10 +160,10 @@ modified: 2024-01-01
|
||||
|
||||
# Verify both entities and their relations
|
||||
entity_a = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/entity_a"
|
||||
"concept/entity-a"
|
||||
)
|
||||
entity_b = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/entity_b"
|
||||
"concept/entity-b"
|
||||
)
|
||||
|
||||
# outgoing relations
|
||||
@@ -213,7 +213,7 @@ modified: 2024-01-01
|
||||
content = """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/duplicate_relations
|
||||
permalink: concept/duplicate-relations
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -235,7 +235,7 @@ modified: 2024-01-01
|
||||
|
||||
# Verify duplicates are handled
|
||||
entity = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/duplicate_relations"
|
||||
"concept/duplicate-relations"
|
||||
)
|
||||
|
||||
# Count relations by type
|
||||
@@ -258,7 +258,7 @@ async def test_sync_entity_with_invalid_category(
|
||||
content = """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/invalid_category
|
||||
permalink: concept/invalid-category
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -277,7 +277,7 @@ modified: 2024-01-01
|
||||
|
||||
# Verify observations
|
||||
entity = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/invalid_category"
|
||||
"concept/invalid-category"
|
||||
)
|
||||
|
||||
assert len(entity.observations) == 3
|
||||
@@ -301,7 +301,7 @@ async def test_sync_entity_with_order_dependent_relations(
|
||||
"a": """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/entity_a
|
||||
permalink: concept/entity-a
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -312,13 +312,13 @@ modified: 2024-01-01
|
||||
- depends on c
|
||||
|
||||
## Relations
|
||||
- depends_on [[concept/entity_b]]
|
||||
- depends_on [[concept/entity_c]]
|
||||
- depends_on [[concept/entity-b]]
|
||||
- depends_on [[concept/entity-c]]
|
||||
""",
|
||||
"b": """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/entity_b
|
||||
permalink: concept/entity-b
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -328,12 +328,12 @@ modified: 2024-01-01
|
||||
- depends on c
|
||||
|
||||
## Relations
|
||||
- depends_on [[concept/entity_c]]
|
||||
- depends_on [[concept/entity-c]]
|
||||
""",
|
||||
"c": """
|
||||
---
|
||||
type: knowledge
|
||||
permalink: concept/entity_c
|
||||
permalink: concept/entity-c
|
||||
created: 2024-01-01
|
||||
modified: 2024-01-01
|
||||
---
|
||||
@@ -343,7 +343,7 @@ modified: 2024-01-01
|
||||
- depends on a
|
||||
|
||||
## Relations
|
||||
- depends_on [[concept/entity_a]]
|
||||
- depends_on [[concept/entity-a]]
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -356,13 +356,13 @@ modified: 2024-01-01
|
||||
|
||||
# Verify all relations are created correctly regardless of order
|
||||
entity_a = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/entity_a"
|
||||
"concept/entity-a"
|
||||
)
|
||||
entity_b = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/entity_b"
|
||||
"concept/entity-b"
|
||||
)
|
||||
entity_c = await sync_service.entity_sync_service.entity_repository.get_by_permalink(
|
||||
"concept/entity_c"
|
||||
"concept/entity-c"
|
||||
)
|
||||
|
||||
assert len(entity_a.outgoing_relations) == 2 # Should depend on B and C
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.utils.file_utils import (
|
||||
from basic_memory.file_utils import (
|
||||
compute_checksum,
|
||||
ensure_directory,
|
||||
write_file_atomic,
|
||||
@@ -86,7 +86,7 @@ async def test_add_frontmatter():
|
||||
assert "- a\n- b" in result or "['a', 'b']" in result
|
||||
|
||||
# Should preserve content
|
||||
assert result.endswith(f"test content")
|
||||
assert result.endswith("test content")
|
||||
|
||||
|
||||
def test_has_frontmatter():
|
||||
@@ -127,7 +127,7 @@ tags:
|
||||
- b
|
||||
---
|
||||
content"""
|
||||
|
||||
|
||||
result = parse_frontmatter(content)
|
||||
assert result == {"title": "Test", "tags": ["a", "b"]}
|
||||
|
||||
@@ -177,9 +177,12 @@ title: Test
|
||||
assert remove_frontmatter(content) == ""
|
||||
|
||||
# frontmatter missing some fields
|
||||
assert remove_frontmatter("""---
|
||||
assert (
|
||||
remove_frontmatter("""---
|
||||
title: Test
|
||||
content""") == "---\ntitle: Test\ncontent"
|
||||
content""")
|
||||
== "---\ntitle: Test\ncontent"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -248,4 +251,4 @@ content"""
|
||||
title: Test
|
||||
---
|
||||
content """
|
||||
assert remove_frontmatter(content).strip() == "content"
|
||||
assert remove_frontmatter(content).strip() == "content"
|
||||
|
||||
Reference in New Issue
Block a user