mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6b46908b1 | |||
| 0689e7a730 | |||
| 16466e9269 | |||
| 39bd5ca08f | |||
| 4a1f5452c4 | |||
| 9c9960e2ca | |||
| 30cd74ec95 |
@@ -1,6 +1,30 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v0.5.0 (2025-02-18)
|
||||
|
||||
### Features
|
||||
|
||||
- Return semantic info in markdown after write_note
|
||||
([#11](https://github.com/basicmachines-co/basic-memory/pull/11),
|
||||
[`0689e7a`](https://github.com/basicmachines-co/basic-memory/commit/0689e7a730497827bf4e16156ae402ddc5949077))
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
|
||||
## v0.4.3 (2025-02-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Re do enhanced read note format ([#10](https://github.com/basicmachines-co/basic-memory/pull/10),
|
||||
[`39bd5ca`](https://github.com/basicmachines-co/basic-memory/commit/39bd5ca08fd057220b95a8b5d82c5e73a1f5722b))
|
||||
|
||||
Co-authored-by: phernandez <phernandez@basicmachines.co>
|
||||
|
||||
|
||||
## v0.4.2 (2025-02-17)
|
||||
|
||||
|
||||
## v0.4.1 (2025-02-17)
|
||||
|
||||
### Bug Fixes
|
||||
@@ -8,6 +32,9 @@
|
||||
- Fix alemic config
|
||||
([`71de8ac`](https://github.com/basicmachines-co/basic-memory/commit/71de8acfd0902fc60f27deb3638236a3875787ab))
|
||||
|
||||
- More alembic fixes
|
||||
([`30cd74e`](https://github.com/basicmachines-co/basic-memory/commit/30cd74ec95c04eaa92b41b9815431f5fbdb46ef8))
|
||||
|
||||
|
||||
## v0.4.0 (2025-02-16)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "basic-memory"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12.1"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
__version__ = "0.4.1"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
@@ -1,34 +1,118 @@
|
||||
"""Routes for getting entity content."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ProjectConfigDep, LinkResolverDep
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigDep,
|
||||
LinkResolverDep,
|
||||
SearchServiceDep,
|
||||
EntityServiceDep,
|
||||
FileServiceDep,
|
||||
)
|
||||
from basic_memory.repository.search_repository import SearchIndexRow
|
||||
from basic_memory.schemas.memory import normalize_memory_url
|
||||
from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
|
||||
router = APIRouter(prefix="/resource", tags=["resources"])
|
||||
|
||||
|
||||
def get_entity_ids(item: SearchIndexRow) -> list[int]:
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return [item.id]
|
||||
case SearchItemType.OBSERVATION:
|
||||
return [item.entity_id] # pyright: ignore [reportReturnType]
|
||||
case SearchItemType.RELATION:
|
||||
from_entity = item.from_id
|
||||
to_entity = item.to_id # pyright: ignore [reportReturnType]
|
||||
return [from_entity, to_entity] if to_entity else [from_entity] # pyright: ignore [reportReturnType]
|
||||
case _: # pragma: no cover
|
||||
raise ValueError(f"Unexpected type: {item.type}")
|
||||
|
||||
|
||||
@router.get("/{identifier:path}")
|
||||
async def get_resource_content(
|
||||
config: ProjectConfigDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
identifier: str,
|
||||
) -> FileResponse:
|
||||
"""Get resource content by identifier: name or permalink."""
|
||||
logger.debug(f"Getting content for permalink: {identifier}")
|
||||
logger.debug(f"Getting content for: {identifier}")
|
||||
|
||||
# Find entity by permalink
|
||||
# Find single entity by permalink
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity not found: {identifier}")
|
||||
results = [entity] if entity else []
|
||||
|
||||
file_path = Path(f"{config.home}/{entity.file_path}")
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {file_path}",
|
||||
# search using the identifier as a permalink
|
||||
if not results:
|
||||
# if the identifier contains a wildcard, use GLOB search
|
||||
query = (
|
||||
SearchQuery(permalink_match=identifier)
|
||||
if "*" in identifier
|
||||
else SearchQuery(permalink=identifier)
|
||||
)
|
||||
return FileResponse(path=file_path)
|
||||
search_results = await search_service.search(query)
|
||||
if not search_results:
|
||||
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
|
||||
|
||||
# get the entities related to the search results
|
||||
entity_ids = [id for result in search_results for id in get_entity_ids(result)]
|
||||
results = await entity_service.get_entities_by_id(entity_ids)
|
||||
|
||||
# return single response
|
||||
if len(results) == 1:
|
||||
entity = results[0]
|
||||
file_path = Path(f"{config.home}/{entity.file_path}")
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {file_path}",
|
||||
)
|
||||
return FileResponse(path=file_path)
|
||||
|
||||
# for multiple files, initialize a temporary file for writing the results
|
||||
with tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".md") as tmp_file:
|
||||
temp_file_path = tmp_file.name
|
||||
|
||||
for result in results:
|
||||
# Read content for each entity
|
||||
content = await file_service.read_entity_content(result)
|
||||
memory_url = normalize_memory_url(result.permalink)
|
||||
modified_date = result.updated_at.isoformat()
|
||||
assert result.checksum
|
||||
checksum = result.checksum[:8]
|
||||
|
||||
# Prepare the delimited content
|
||||
response_content = f"--- {memory_url} {modified_date} {checksum}\n"
|
||||
response_content += f"\n{content}\n"
|
||||
response_content += "\n"
|
||||
|
||||
# Write content directly to the temporary file in append mode
|
||||
tmp_file.write(response_content)
|
||||
|
||||
# Ensure all content is written to disk
|
||||
tmp_file.flush()
|
||||
|
||||
# Schedule the temporary file to be deleted after the response
|
||||
background_tasks.add_task(cleanup_temp_file, temp_file_path)
|
||||
|
||||
# Return the file response
|
||||
return FileResponse(path=temp_file_path)
|
||||
|
||||
|
||||
def cleanup_temp_file(file_path: str):
|
||||
"""Delete the temporary file."""
|
||||
try:
|
||||
Path(file_path).unlink() # Deletes the file
|
||||
logger.debug(f"Temporary file deleted: {file_path}")
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error deleting temporary file {file_path}: {e}")
|
||||
|
||||
@@ -69,7 +69,11 @@ def traverse_messages(
|
||||
|
||||
|
||||
def format_chat_markdown(
|
||||
title: str, mapping: Dict[str, Any], root_id: Optional[str], created_at: float, modified_at: float
|
||||
title: str,
|
||||
mapping: Dict[str, Any],
|
||||
root_id: Optional[str],
|
||||
created_at: float,
|
||||
modified_at: float,
|
||||
) -> str:
|
||||
"""Format chat as clean markdown."""
|
||||
|
||||
|
||||
+12
-2
@@ -137,8 +137,18 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
|
||||
# Get the absolute path to the alembic directory relative to this file
|
||||
alembic_dir = Path(__file__).parent / "alembic"
|
||||
config = Config()
|
||||
|
||||
# Set required Alembic config options programmatically
|
||||
config.set_main_option("script_location", str(alembic_dir))
|
||||
config.set_main_option("sqlalchemy.url", "driver://user:pass@localhost/dbname")
|
||||
config.set_main_option(
|
||||
"file_template",
|
||||
"%%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s",
|
||||
)
|
||||
config.set_main_option("timezone", "UTC")
|
||||
config.set_main_option("revision_environment", "false")
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
|
||||
)
|
||||
|
||||
command.upgrade(config, "head")
|
||||
logger.info("Migrations completed successfully")
|
||||
@@ -147,4 +157,4 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
|
||||
await SearchRepository(session_maker).init_search_index()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error running migrations: {e}")
|
||||
raise
|
||||
raise
|
||||
|
||||
@@ -17,42 +17,62 @@ from basic_memory.schemas.memory import memory_url_path
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Create or update a markdown note. Returns the permalink for referencing.",
|
||||
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
|
||||
)
|
||||
async def write_note(
|
||||
title: str,
|
||||
content: str,
|
||||
folder: str,
|
||||
tags: Optional[List[str]] = None,
|
||||
verbose: bool = False,
|
||||
) -> EntityResponse | str:
|
||||
) -> str:
|
||||
"""Write a markdown note to the knowledge base.
|
||||
|
||||
The content can include semantic observations and relations using markdown syntax.
|
||||
Relations can be specified either explicitly or through inline wiki-style links:
|
||||
|
||||
Observations format:
|
||||
`- [category] Observation text #tag1 #tag2 (optional context)`
|
||||
|
||||
Examples:
|
||||
`- [design] Files are the source of truth #architecture (All state comes from files)`
|
||||
`- [tech] Using SQLite for storage #implementation`
|
||||
`- [note] Need to add error handling #todo`
|
||||
|
||||
Relations format:
|
||||
- Explicit: `- relation_type [[Entity]] (optional context)`
|
||||
- Inline: Any `[[Entity]]` reference creates a relation
|
||||
|
||||
Examples:
|
||||
`- depends_on [[Content Parser]] (Need for semantic extraction)`
|
||||
`- implements [[Search Spec]] (Initial implementation)`
|
||||
`- This feature extends [[Base Design]] and uses [[Core Utils]]`
|
||||
|
||||
Args:
|
||||
title: The title of the note
|
||||
content: Markdown content for the note
|
||||
content: Markdown content for the note, can include observations and relations
|
||||
folder: the folder where the file should be saved
|
||||
tags: Optional list of tags to categorize the note
|
||||
verbose: If True, returns full EntityResponse with semantic info
|
||||
|
||||
Returns:
|
||||
If verbose=False: Permalink that can be used to reference the note
|
||||
If verbose=True: EntityResponse with full semantic details
|
||||
A markdown formatted summary of the semantic content, including:
|
||||
- Creation/update status
|
||||
- File path and checksum
|
||||
- Observation counts by category
|
||||
- Relation counts (resolved/unresolved)
|
||||
- Tags if present
|
||||
|
||||
Examples:
|
||||
# Create a simple note
|
||||
write_note(
|
||||
tile="Meeting Notes: Project Planning.md",
|
||||
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
|
||||
folder="notes"
|
||||
)
|
||||
|
||||
# Create note with tags
|
||||
write_note(
|
||||
title="Security Review",
|
||||
content="# Findings\\n\\n1. Updated auth flow\\n2. Added rate limiting",
|
||||
folder="security",
|
||||
tags=["security", "development"]
|
||||
title="Search Implementation",
|
||||
content="# Search Component\\n\\n"
|
||||
"Implementation of the search feature, building on [[Core Search]].\\n\\n"
|
||||
"## Observations\\n"
|
||||
"- [tech] Using FTS5 for full-text search #implementation\\n"
|
||||
"- [design] Need pagination support #todo\\n\\n"
|
||||
"## Relations\\n"
|
||||
"- implements [[Search Spec]]\\n"
|
||||
"- depends_on [[Database Schema]]",
|
||||
folder="docs/components"
|
||||
)
|
||||
"""
|
||||
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
|
||||
@@ -68,34 +88,97 @@ async def write_note(
|
||||
entity_metadata=metadata,
|
||||
)
|
||||
|
||||
# Use existing knowledge tool
|
||||
# Create or update via knowledge API
|
||||
logger.info(f"Creating {entity.permalink}")
|
||||
url = f"/knowledge/entities/{entity.permalink}"
|
||||
response = await call_put(client, url, json=entity.model_dump())
|
||||
result = EntityResponse.model_validate(response.json())
|
||||
return result if verbose else result.permalink
|
||||
|
||||
# Format semantic summary based on status code
|
||||
action = "Created" if response.status_code == 201 else "Updated"
|
||||
assert result.checksum is not None
|
||||
summary = [
|
||||
f"# {action} {result.file_path} ({result.checksum[:8]})",
|
||||
f"permalink: {result.permalink}",
|
||||
]
|
||||
|
||||
if result.observations:
|
||||
categories = {}
|
||||
for obs in result.observations:
|
||||
categories[obs.category] = categories.get(obs.category, 0) + 1
|
||||
|
||||
summary.append("\n## Observations")
|
||||
for category, count in sorted(categories.items()):
|
||||
summary.append(f"- {category}: {count}")
|
||||
|
||||
if result.relations:
|
||||
unresolved = sum(1 for r in result.relations if not r.to_id)
|
||||
resolved = len(result.relations) - unresolved
|
||||
|
||||
summary.append("\n## Relations")
|
||||
summary.append(f"- Resolved: {resolved}")
|
||||
if unresolved:
|
||||
summary.append(f"- Unresolved: {unresolved}")
|
||||
summary.append("\nUnresolved relations will be retried on next sync.")
|
||||
|
||||
if tags:
|
||||
summary.append(f"\n## Tags\n- {', '.join(tags)}")
|
||||
|
||||
return "\n".join(summary)
|
||||
|
||||
|
||||
@mcp.tool(description="Read a note's content by its title or permalink")
|
||||
@mcp.tool(description="Read note content by title, permalink, relation, or pattern")
|
||||
async def read_note(identifier: str) -> str:
|
||||
"""Get the markdown content of a note.
|
||||
Uses the resource router to return the actual file content.
|
||||
"""Get note content in unified diff format.
|
||||
|
||||
The content is returned in a unified diff inspired format:
|
||||
```
|
||||
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
|
||||
<document content>
|
||||
```
|
||||
|
||||
Multiple documents (from relations or pattern matches) are separated by
|
||||
additional headers.
|
||||
|
||||
Args:
|
||||
identifier: Note title or permalink
|
||||
identifier: Can be one of:
|
||||
- Note title ("Project Planning")
|
||||
- Note permalink ("docs/example")
|
||||
- Relation path ("docs/example/depends-on/other-doc")
|
||||
- Pattern match ("docs/*-architecture")
|
||||
|
||||
Returns:
|
||||
The note's markdown content
|
||||
Document content in unified diff format. For single documents, returns
|
||||
just that document's content. For relations or pattern matches, returns
|
||||
multiple documents separated by unified diff headers.
|
||||
|
||||
Examples:
|
||||
# Read by title
|
||||
read_note("Meeting Notes: Project Planning")
|
||||
# Single document
|
||||
content = await read_note("Project Planning")
|
||||
|
||||
# Read by permalink
|
||||
read_note("notes/project-planning")
|
||||
content = await read_note("docs/architecture/file-first")
|
||||
|
||||
Raises:
|
||||
ValueError: If the note cannot be found
|
||||
# Follow relation
|
||||
content = await read_note("docs/architecture/depends-on/docs/content-parser")
|
||||
|
||||
# Pattern matching
|
||||
content = await read_note("docs/*-architecture") # All architecture docs
|
||||
content = await read_note("docs/*/implements/*") # Find implementations
|
||||
|
||||
Output format:
|
||||
```
|
||||
--- memory://docs/example 2025-01-31T19:32:49 7d9f1c8b
|
||||
<first document content>
|
||||
|
||||
--- memory://docs/other 2025-01-30T15:45:22 a1b2c3d4
|
||||
<second document content>
|
||||
```
|
||||
|
||||
The headers include:
|
||||
- Full memory:// URI for the document
|
||||
- Last modified timestamp
|
||||
- Content checksum
|
||||
"""
|
||||
logger.info(f"Reading note {identifier}")
|
||||
url = memory_url_path(identifier)
|
||||
|
||||
@@ -68,24 +68,40 @@ class SearchRepository:
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create or recreate the search index."""
|
||||
|
||||
logger.info("Initializing search index")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
try:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
await session.execute(CREATE_SEARCH_INDEX)
|
||||
await session.commit()
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error initializing search index: {e}")
|
||||
raise e
|
||||
|
||||
def _quote_search_term(self, term: str) -> str:
|
||||
"""Add quotes if term contains special characters.
|
||||
For FTS5, special characters and phrases need to be quoted to be treated as a single token.
|
||||
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
|
||||
"""Prepare a search term for FTS5 query.
|
||||
|
||||
Args:
|
||||
term: The search term to prepare
|
||||
is_prefix: Whether to add prefix search capability (* suffix)
|
||||
|
||||
For FTS5:
|
||||
- Special characters and phrases need to be quoted
|
||||
- Terms with spaces or special chars need quotes
|
||||
"""
|
||||
# List of special characters that need quoting
|
||||
special_chars = ["/", "*", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
||||
if "*" in term:
|
||||
return term
|
||||
|
||||
# List of special characters that need quoting (excluding *)
|
||||
special_chars = ["/", "-", ".", " ", "(", ")", "[", "]", '"', "'"]
|
||||
|
||||
# Check if term contains any special characters
|
||||
if any(c in term for c in special_chars):
|
||||
# If the term already contains quotes, escape them
|
||||
needs_quotes = any(c in term for c in special_chars)
|
||||
|
||||
if needs_quotes:
|
||||
# If the term already contains quotes, escape them and add a wildcard
|
||||
term = term.replace('"', '""')
|
||||
return f'"{term}"'
|
||||
term = f'"{term}"*'
|
||||
|
||||
return term
|
||||
|
||||
async def search(
|
||||
@@ -106,14 +122,14 @@ class SearchRepository:
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
search_text = self._quote_search_term(search_text.lower().strip())
|
||||
params["text"] = f"{search_text}*"
|
||||
search_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = search_text
|
||||
conditions.append("(title MATCH :text OR content MATCH :text)")
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
title_text = self._quote_search_term(title.lower().strip())
|
||||
params["text"] = f"{title_text}*"
|
||||
title_text = self._prepare_search_term(title.strip())
|
||||
params["text"] = title_text
|
||||
conditions.append("title MATCH :text")
|
||||
|
||||
# Handle permalink exact search
|
||||
@@ -123,8 +139,15 @@ class SearchRepository:
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
params["permalink"] = self._quote_search_term(permalink_match)
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
# Clean and prepare permalink for FTS5 GLOB match
|
||||
permalink_text = self._prepare_search_term(
|
||||
permalink_match.lower().strip(), is_prefix=False
|
||||
)
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
else:
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
@@ -173,7 +196,7 @@ class SearchRepository:
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
# logger.debug(f"Search {sql} params: {params}")
|
||||
logger.debug(f"Search {sql} params: {params}")
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text(sql), params)
|
||||
rows = result.fetchall()
|
||||
@@ -199,8 +222,11 @@ class SearchRepository:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# for r in results:
|
||||
# logger.debug(f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}")
|
||||
logger.debug(f"Found {len(results)} search results")
|
||||
for r in results:
|
||||
logger.debug(
|
||||
f"Search result: type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -233,7 +259,7 @@ class SearchRepository:
|
||||
"""),
|
||||
search_index_row.to_insert(),
|
||||
)
|
||||
logger.debug(f"indexed permalink {search_index_row.permalink}")
|
||||
logger.debug(f"indexed row {search_index_row}")
|
||||
await session.commit()
|
||||
|
||||
async def delete_by_permalink(self, permalink: str):
|
||||
|
||||
@@ -11,6 +11,7 @@ Key Features:
|
||||
4. Bulk operations return all affected items
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, AliasPath, AliasChoices
|
||||
@@ -43,6 +44,8 @@ class ObservationResponse(Observation, SQLAlchemyModel):
|
||||
}
|
||||
"""
|
||||
|
||||
permalink: Permalink
|
||||
|
||||
|
||||
class RelationResponse(Relation, SQLAlchemyModel):
|
||||
"""Response schema for relation operations.
|
||||
@@ -59,6 +62,8 @@ class RelationResponse(Relation, SQLAlchemyModel):
|
||||
}
|
||||
"""
|
||||
|
||||
permalink: Permalink
|
||||
|
||||
from_id: Permalink = Field(
|
||||
# use the permalink from the associated Entity
|
||||
# or the from_id value
|
||||
@@ -131,9 +136,12 @@ class EntityResponse(SQLAlchemyModel):
|
||||
file_path: str
|
||||
entity_type: EntityType
|
||||
entity_metadata: Optional[Dict] = None
|
||||
checksum: Optional[str] = None
|
||||
content_type: ContentType
|
||||
observations: List[ObservationResponse] = []
|
||||
relations: List[RelationResponse] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EntityListResponse(SQLAlchemyModel):
|
||||
|
||||
@@ -185,6 +185,11 @@ class EntityService(BaseService[EntityModel]):
|
||||
raise EntityNotFoundError(f"Entity not found: {permalink}")
|
||||
return db_entity
|
||||
|
||||
async def get_entities_by_id(self, ids: List[int]) -> Sequence[EntityModel]:
|
||||
"""Get specific entities and their relationships."""
|
||||
logger.debug(f"Getting entities: {ids}")
|
||||
return await self.repository.find_by_ids(ids)
|
||||
|
||||
async def get_entities_by_permalinks(self, permalinks: List[str]) -> Sequence[EntityModel]:
|
||||
"""Get specific nodes and their relationships."""
|
||||
logger.debug(f"Getting entities permalinks: {permalinks}")
|
||||
|
||||
@@ -16,9 +16,10 @@ class LinkResolver:
|
||||
|
||||
Uses a combination of exact matching and search-based resolution:
|
||||
1. Try exact permalink match (fastest)
|
||||
2. Try exact title match
|
||||
3. Fall back to search for fuzzy matching
|
||||
4. Generate new permalink if no match found
|
||||
2. Try permalink pattern match (for wildcards)
|
||||
3. Try exact title match
|
||||
4. Fall back to search for fuzzy matching
|
||||
5. Generate new permalink if no match found
|
||||
"""
|
||||
|
||||
def __init__(self, entity_repository: EntityRepository, search_service: SearchService):
|
||||
@@ -45,8 +46,8 @@ class LinkResolver:
|
||||
logger.debug(f"Found title match: {entity.title}")
|
||||
return entity
|
||||
|
||||
if use_search:
|
||||
# 3. Fall back to search for fuzzy matching on title if specified
|
||||
if use_search and "*" not in clean_text:
|
||||
# 3. Fall back to search for fuzzy matching on title
|
||||
results = await self.search_service.search(
|
||||
query=SearchQuery(title=clean_text, types=[SearchItemType.ENTITY]),
|
||||
)
|
||||
@@ -59,7 +60,7 @@ class LinkResolver:
|
||||
)
|
||||
return await self.entity_repository.get_by_permalink(best_match.permalink)
|
||||
|
||||
# if we couldn't find anything then return None
|
||||
# if we couldn't find anything then return None
|
||||
return None
|
||||
|
||||
def _normalize_link_text(self, link_text: str) -> Tuple[str, Optional[str]]:
|
||||
@@ -87,7 +88,7 @@ class LinkResolver:
|
||||
|
||||
return text, alias
|
||||
|
||||
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> Entity:
|
||||
def _select_best_match(self, search_text: str, results: List[SearchIndexRow]) -> SearchIndexRow:
|
||||
"""Select best match from search results.
|
||||
|
||||
Uses multiple criteria:
|
||||
|
||||
@@ -75,7 +75,7 @@ class SearchService:
|
||||
else None
|
||||
)
|
||||
|
||||
# permalink search
|
||||
# search
|
||||
results = await self.repository.search(
|
||||
search_text=query.text,
|
||||
permalink=query.permalink,
|
||||
|
||||
@@ -5,6 +5,8 @@ from datetime import datetime, timezone
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_content(client, test_config, entity_repository):
|
||||
@@ -67,7 +69,7 @@ async def test_get_resource_missing_entity(client):
|
||||
"""Test 404 when entity doesn't exist."""
|
||||
response = await client.get("/resource/does/not/exist")
|
||||
assert response.status_code == 404
|
||||
assert "Entity not found" in response.json()["detail"]
|
||||
assert "Resource not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -89,3 +91,138 @@ async def test_get_resource_missing_file(client, test_config, entity_repository)
|
||||
response = await client.get(f"/resource/{entity.permalink}")
|
||||
assert response.status_code == 404
|
||||
assert "File not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_observation(client, test_config, entity_repository):
|
||||
"""Test getting content by observation permalink."""
|
||||
# Create entity
|
||||
content = "# Test Content\n\n- [note] an observation."
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity.observations) == 1
|
||||
observation = entity.observations[0]
|
||||
|
||||
# Test getting the content via the observation
|
||||
response = await client.get(f"/resource/{observation.permalink}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Test Entity
|
||||
type: test
|
||||
permalink: test/test-entity
|
||||
---
|
||||
|
||||
# Test Content
|
||||
|
||||
- [note] an observation.
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_entities(client, test_config, entity_repository):
|
||||
"""Test getting content by permalink match."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
|
||||
# Test getting the content via the relation
|
||||
response = await client.get("/resource/test/*")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
f"""
|
||||
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
|
||||
|
||||
# Test Content
|
||||
|
||||
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_relation(client, test_config, entity_repository):
|
||||
"""Test getting content by relation permalink."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post("/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
relation = entity2.relations[0]
|
||||
|
||||
# Test getting the content via the relation
|
||||
response = await client.get(f"/resource/{relation.permalink}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
f"""
|
||||
--- memory://test/test-entity {entity1.updated_at.isoformat()} {entity1.checksum[:8]}
|
||||
|
||||
# Test Content
|
||||
|
||||
--- memory://test/related-entity {entity2.updated_at.isoformat()} {entity2.checksum[:8]}
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
|
||||
""".strip()
|
||||
in response.text
|
||||
)
|
||||
|
||||
@@ -308,27 +308,6 @@ async def test_graph(
|
||||
for entity in entities:
|
||||
await search_service.index_entity(entity)
|
||||
|
||||
# search_content = await entity_repository.execute_query(text("select * from search_index"),
|
||||
# use_query_options=False)
|
||||
# for row in search_content:
|
||||
# print(row)
|
||||
# print("relation:")
|
||||
# search_content = await entity_repository.execute_query(text("select * from search_index where type = 'relation'"),
|
||||
# use_query_options=False)
|
||||
# for row in search_content:
|
||||
# print(row)
|
||||
#
|
||||
# # In test_graph fixture after creating everything:
|
||||
# print("Entities:")
|
||||
# entities = await entity_repository.find_all()
|
||||
# for e in entities:
|
||||
# print(f"- {e.title} (id={e.id})")
|
||||
#
|
||||
# print("\nRelations:")
|
||||
# relations = await relation_repository.find_all()
|
||||
# for r in relations:
|
||||
# print(f"- {r.from_id} -> {r.to_id} ({r.relation_type})")
|
||||
|
||||
return {
|
||||
"root": root,
|
||||
"connected1": connected_1,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Tests for get_entity MCP tool."""
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.tools import notes
|
||||
from basic_memory.mcp.tools.knowledge import get_entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_basic_entity(client):
|
||||
"""Test retrieving a basic entity."""
|
||||
# First create an entity
|
||||
permalink = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
# Test\nThis is a test note
|
||||
- [note] First observation
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
|
||||
# Get the entity without content
|
||||
entity = await get_entity(permalink)
|
||||
|
||||
# Verify entity details
|
||||
assert entity.file_path == "test/Test Note.md"
|
||||
assert entity.entity_type == "note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
|
||||
# Check observations
|
||||
assert len(entity.observations) == 1
|
||||
obs = entity.observations[0]
|
||||
assert obs.content == "First observation"
|
||||
assert obs.category == "note"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_entity(client):
|
||||
"""Test attempting to get a non-existent entity."""
|
||||
with pytest.raises(ToolError):
|
||||
await get_entity("test/nonexistent")
|
||||
@@ -13,7 +13,7 @@ from basic_memory.schemas.delete import DeleteEntitiesRequest
|
||||
async def test_get_single_entity(client):
|
||||
"""Test retrieving a single entity."""
|
||||
# First create an entity
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
@@ -22,9 +22,10 @@ async def test_get_single_entity(client):
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Get the entity
|
||||
entity = await get_entity(permalink)
|
||||
entity = await get_entity("test/test-note")
|
||||
|
||||
# Verify entity details
|
||||
assert entity.title == "Test Note"
|
||||
@@ -36,40 +37,40 @@ async def test_get_single_entity(client):
|
||||
async def test_get_multiple_entities(client):
|
||||
"""Test retrieving multiple entities."""
|
||||
# Create two test entities
|
||||
permalink1 = await notes.write_note(
|
||||
await notes.write_note(
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
content="# Test 1",
|
||||
)
|
||||
permalink2 = await notes.write_note(
|
||||
await notes.write_note(
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
content="# Test 2",
|
||||
)
|
||||
|
||||
# Get both entities
|
||||
request = GetEntitiesRequest(permalinks=[permalink1, permalink2])
|
||||
request = GetEntitiesRequest(permalinks=["test/test-note-1", "test/test-note-2"])
|
||||
response = await get_entities(request)
|
||||
|
||||
# Verify we got both entities
|
||||
assert len(response.entities) == 2
|
||||
permalinks = {e.permalink for e in response.entities}
|
||||
assert permalink1 in permalinks
|
||||
assert permalink2 in permalinks
|
||||
assert "test/test-note-1" in permalinks
|
||||
assert "test/test-note-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_entities(client):
|
||||
"""Test deleting entities."""
|
||||
# Create a test entity
|
||||
permalink = await notes.write_note(
|
||||
await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test Note to Delete",
|
||||
)
|
||||
|
||||
# Delete the entity
|
||||
request = DeleteEntitiesRequest(permalinks=[permalink])
|
||||
request = DeleteEntitiesRequest(permalinks=["test/test-note"])
|
||||
response = await delete_entities(request)
|
||||
|
||||
# Verify deletion
|
||||
@@ -77,7 +78,7 @@ async def test_delete_entities(client):
|
||||
|
||||
# Verify entity no longer exists
|
||||
with pytest.raises(ToolError):
|
||||
await get_entity(permalink)
|
||||
await get_entity("test/test-note")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
+130
-67
@@ -1,10 +1,11 @@
|
||||
"""Tests for note tools that exercise the full stack with SQLite."""
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.tools import notes
|
||||
from basic_memory.schemas import EntityResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -17,31 +18,41 @@ async def test_write_note(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
assert result
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (159f2168)
|
||||
permalink: test/test-note
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
# Try reading it back via permalink
|
||||
content = await notes.read_note(permalink)
|
||||
content = await notes.read_note("test/test-note")
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
title: Test Note
|
||||
type: note
|
||||
permalink: test/test-note
|
||||
tags:
|
||||
- '#test'
|
||||
- '#documentation'
|
||||
---
|
||||
|
||||
# Test
|
||||
This is a test note
|
||||
""".strip()
|
||||
dedent("""
|
||||
---
|
||||
title: Test Note
|
||||
type: note
|
||||
permalink: test/test-note
|
||||
tags:
|
||||
- '#test'
|
||||
- '#documentation'
|
||||
---
|
||||
|
||||
# Test
|
||||
This is a test note
|
||||
""").strip()
|
||||
in content
|
||||
)
|
||||
|
||||
@@ -49,20 +60,28 @@ This is a test note
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_note_no_tags(app):
|
||||
"""Test creating a note without tags."""
|
||||
permalink = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
|
||||
result = await notes.write_note(title="Simple Note", folder="test", content="Just some text")
|
||||
|
||||
# Should be able to read it back
|
||||
content = await notes.read_note(permalink)
|
||||
assert result
|
||||
assert (
|
||||
"""
|
||||
--
|
||||
title: Simple Note
|
||||
type: note
|
||||
permalink: test/simple-note
|
||||
---
|
||||
|
||||
Just some text
|
||||
""".strip()
|
||||
dedent("""
|
||||
# Created test/Simple Note.md (9a1ff079)
|
||||
permalink: test/simple-note
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
# Should be able to read it back
|
||||
content = await notes.read_note("test/simple-note")
|
||||
assert (
|
||||
dedent("""
|
||||
--
|
||||
title: Simple Note
|
||||
type: note
|
||||
permalink: test/simple-note
|
||||
---
|
||||
|
||||
Just some text
|
||||
""").strip()
|
||||
in content
|
||||
)
|
||||
|
||||
@@ -84,24 +103,44 @@ async def test_write_note_update_existing(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
assert result # Got a valid permalink
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (159f2168)
|
||||
permalink: test/test-note
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is an updated note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
assert (
|
||||
dedent("""
|
||||
# Updated test/Test Note.md (131b5662)
|
||||
permalink: test/test-note
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
# Try reading it back
|
||||
content = await notes.read_note(permalink)
|
||||
content = await notes.read_note("test/test-note")
|
||||
assert (
|
||||
"""
|
||||
---
|
||||
@@ -135,10 +174,18 @@ async def test_read_note_by_title(app):
|
||||
async def test_note_unicode_content(app):
|
||||
"""Test handling of unicode content in notes."""
|
||||
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
|
||||
permalink = await notes.write_note(title="Unicode Test", folder="test", content=content)
|
||||
result = await notes.write_note(title="Unicode Test", folder="test", content=content)
|
||||
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Unicode Test.md (272389cd)
|
||||
permalink: test/unicode-test
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
# Read back should preserve unicode
|
||||
result = await notes.read_note(permalink)
|
||||
result = await notes.read_note("test/unicode-test")
|
||||
assert content in result
|
||||
|
||||
|
||||
@@ -147,20 +194,32 @@ async def test_multiple_notes(app):
|
||||
"""Test creating and managing multiple notes."""
|
||||
# Create several notes
|
||||
notes_data = [
|
||||
("Note 1", "test", "Content 1", ["tag1"]),
|
||||
("Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("Note 3", "test", "Content 3", []),
|
||||
("test/note-1", "Note 1", "test", "Content 1", ["tag1"]),
|
||||
("test/note-2", "Note 2", "test", "Content 2", ["tag1", "tag2"]),
|
||||
("test/note-3", "Note 3", "test", "Content 3", []),
|
||||
]
|
||||
|
||||
permalinks = []
|
||||
for title, folder, content, tags in notes_data:
|
||||
permalink = await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
permalinks.append(permalink)
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await notes.write_note(title=title, folder=folder, content=content, tags=tags)
|
||||
|
||||
# Should be able to read each one
|
||||
for i, permalink in enumerate(permalinks):
|
||||
content = await notes.read_note(permalink)
|
||||
assert f"Content {i + 1}" in content
|
||||
for permalink, title, folder, content, _ in notes_data:
|
||||
note = await notes.read_note(permalink)
|
||||
assert content in note
|
||||
|
||||
# read multiple notes at once
|
||||
|
||||
result = await notes.read_note("test/*")
|
||||
|
||||
# note we can't compare times
|
||||
assert "--- memory://test/note-1" in result
|
||||
assert "Content 1" in result
|
||||
|
||||
assert "--- memory://test/note-2" in result
|
||||
assert "Content 2" in result
|
||||
|
||||
assert "--- memory://test/note-3" in result
|
||||
assert "Content 3" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -172,16 +231,16 @@ async def test_delete_note_existing(app):
|
||||
- Return valid permalink
|
||||
- Delete the note
|
||||
"""
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
|
||||
assert permalink # Got a valid permalink
|
||||
assert result
|
||||
|
||||
deleted = await notes.delete_note(permalink)
|
||||
deleted = await notes.delete_note("test/test-note")
|
||||
assert deleted is True
|
||||
|
||||
|
||||
@@ -207,7 +266,7 @@ async def test_write_note_verbose(app):
|
||||
- Handle tags correctly
|
||||
- Return valid permalink
|
||||
"""
|
||||
entity = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
content="""
|
||||
@@ -218,24 +277,27 @@ async def test_write_note_verbose(app):
|
||||
|
||||
""",
|
||||
tags=["test", "documentation"],
|
||||
verbose=True,
|
||||
)
|
||||
|
||||
assert isinstance(entity, EntityResponse)
|
||||
|
||||
assert entity.title == "Test Note"
|
||||
assert entity.file_path == "test/Test Note.md"
|
||||
assert entity.entity_type == "note"
|
||||
assert entity.permalink == "test/test-note"
|
||||
|
||||
assert len(entity.observations) == 1
|
||||
assert entity.observations[0].content == "First observation"
|
||||
|
||||
assert len(entity.relations) == 1
|
||||
assert entity.relations[0].relation_type == "relates to"
|
||||
assert entity.relations[0].from_id == "test/test-note"
|
||||
assert entity.relations[0].to_id is None
|
||||
assert entity.relations[0].to_name == "Knowledge"
|
||||
assert (
|
||||
dedent("""
|
||||
# Created test/Test Note.md (06873a7a)
|
||||
permalink: test/test-note
|
||||
|
||||
## Observations
|
||||
- note: 1
|
||||
|
||||
## Relations
|
||||
- Resolved: 0
|
||||
- Unresolved: 1
|
||||
|
||||
Unresolved relations will be retried on next sync.
|
||||
|
||||
## Tags
|
||||
- test, documentation
|
||||
""").strip()
|
||||
in result
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -248,13 +310,14 @@ async def test_read_note_memory_url(app):
|
||||
- Return the note content
|
||||
"""
|
||||
# First create a note
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
content="Testing memory:// URL handling",
|
||||
)
|
||||
assert result
|
||||
|
||||
# Should be able to read it with a memory:// URL
|
||||
memory_url = f"memory://{permalink}"
|
||||
memory_url = "memory://test/memory-url-test"
|
||||
content = await notes.read_note(memory_url)
|
||||
assert "Testing memory:// URL handling" in content
|
||||
|
||||
@@ -12,12 +12,13 @@ from basic_memory.schemas.search import SearchQuery, SearchItemType
|
||||
async def test_search_basic(client):
|
||||
"""Test basic search functionality."""
|
||||
# Create a test note
|
||||
permalink = await notes.write_note(
|
||||
result = await notes.write_note(
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
assert result
|
||||
|
||||
# Search for it
|
||||
query = SearchQuery(text="searchable")
|
||||
@@ -25,7 +26,7 @@ async def test_search_basic(client):
|
||||
|
||||
# Verify results
|
||||
assert len(response.results) > 0
|
||||
assert any(r.permalink == permalink for r in response.results)
|
||||
assert any(r.permalink == "test/test-search-note" for r in response.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -51,16 +51,17 @@ def test_relation_in_validation():
|
||||
def test_relation_response():
|
||||
"""Test RelationResponse validation."""
|
||||
data = {
|
||||
"permalink": "test/123/relates_to/test/456",
|
||||
"from_id": "test/123",
|
||||
"to_id": "test/456",
|
||||
"relation_type": "test",
|
||||
"relation_type": "relates_to",
|
||||
"from_entity": {"permalink": "test/123"},
|
||||
"to_entity": {"permalink": "test/456"},
|
||||
}
|
||||
relation = RelationResponse.model_validate(data)
|
||||
assert relation.from_id == "test/123"
|
||||
assert relation.to_id == "test/456"
|
||||
assert relation.relation_type == "test"
|
||||
assert relation.relation_type == "relates_to"
|
||||
assert relation.context is None
|
||||
|
||||
|
||||
@@ -73,16 +74,27 @@ def test_entity_out_from_attributes():
|
||||
"file_path": "test",
|
||||
"entity_type": "knowledge",
|
||||
"content_type": "text/markdown",
|
||||
"observations": [{"id": 1, "category": "note", "content": "test obs", "context": None}],
|
||||
"relations": [
|
||||
"observations": [
|
||||
{
|
||||
"id": 1,
|
||||
"from_id": "test/test",
|
||||
"to_id": "test/test",
|
||||
"relation_type": "test",
|
||||
"permalink": "permalink",
|
||||
"category": "note",
|
||||
"content": "test obs",
|
||||
"context": None,
|
||||
}
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"id": 1,
|
||||
"permalink": "test/test/relates_to/test/test",
|
||||
"from_id": "test/test",
|
||||
"to_id": "test/test",
|
||||
"relation_type": "relates_to",
|
||||
"context": None,
|
||||
}
|
||||
],
|
||||
"created_at": "2023-01-01T00:00:00",
|
||||
"updated_at": "2023-01-01T00:00:00",
|
||||
}
|
||||
entity = EntityResponse.model_validate(db_data)
|
||||
assert entity.permalink == "test/test"
|
||||
|
||||
@@ -10,7 +10,17 @@ from basic_memory.services.link_resolver import LinkResolver
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_entities(entity_service, file_service):
|
||||
"""Create a set of test entities."""
|
||||
"""Create a set of test entities.
|
||||
|
||||
├── components
|
||||
│ ├── Auth Service.md
|
||||
│ └── Core Service.md
|
||||
├── config
|
||||
│ └── Service Config.md
|
||||
└── specs
|
||||
└── Core Features.md
|
||||
|
||||
"""
|
||||
|
||||
e1, _ = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
@@ -40,6 +50,20 @@ async def test_entities(entity_service, file_service):
|
||||
folder="specs",
|
||||
)
|
||||
)
|
||||
e5, _ = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Sub Features 1",
|
||||
entity_type="specs",
|
||||
folder="specs/subspec",
|
||||
)
|
||||
)
|
||||
e6, _ = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Sub Features 2",
|
||||
entity_type="specs",
|
||||
folder="specs/subspec",
|
||||
)
|
||||
)
|
||||
|
||||
return [e1, e2, e3, e4]
|
||||
|
||||
@@ -80,6 +104,7 @@ async def test_fuzzy_title_match_misspelling(link_resolver):
|
||||
async def test_fuzzy_title_partial_match(link_resolver):
|
||||
# Test partial match
|
||||
result = await link_resolver.resolve_link("Auth Serv")
|
||||
assert result is not None, "Did not find partial match"
|
||||
assert result.permalink == "components/auth-service"
|
||||
|
||||
|
||||
@@ -114,31 +139,3 @@ async def test_resolve_none(link_resolver):
|
||||
"""Test resolving non-existent entity."""
|
||||
# Basic new entity
|
||||
assert await link_resolver.resolve_link("New Feature") is None
|
||||
|
||||
|
||||
@pytest.mark.skip("Advanced relevance scoring not yet implemented")
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_matches_resolution(link_resolver):
|
||||
"""Test resolution when multiple potential matches exist."""
|
||||
# Add some similar entities
|
||||
test_cases = [
|
||||
{
|
||||
"link": "Service", # Ambiguous
|
||||
"expected_prefix": "components/", # Should prefer component directory match
|
||||
},
|
||||
{
|
||||
"link": "Core", # Ambiguous
|
||||
"expected_prefix": "specs/", # Should prefer specs directory match
|
||||
},
|
||||
{
|
||||
"link": "Service",
|
||||
"expected": "components/core-service", # Should pick shortest/highest scored
|
||||
},
|
||||
]
|
||||
|
||||
for case in test_cases:
|
||||
result = await link_resolver.resolve_link(case["link"])
|
||||
if "expected_prefix" in case:
|
||||
assert result.startswith(case["expected_prefix"])
|
||||
else:
|
||||
assert result == case["expected"]
|
||||
|
||||
@@ -32,21 +32,24 @@ async def test_search_permalink_observations_wildcard(search_service, test_graph
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_relation_wildcard(search_service, test_graph):
|
||||
"""Pattern matching"""
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/root/connects_to/*"))
|
||||
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/connected-entity-1" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.skip("search prefix see:'https://sqlite.org/fts5.html#FTS5 Prefix Queries'")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_permalink_wildcard2(search_service, test_graph):
|
||||
"""Pattern matching"""
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/connected*"))
|
||||
assert len(results) == 2
|
||||
results = await search_service.search(
|
||||
SearchQuery(
|
||||
permalink_match="test/connected*",
|
||||
)
|
||||
)
|
||||
assert len(results) >= 2
|
||||
permalinks = {r.permalink for r in results}
|
||||
assert "test/connected1" in permalinks
|
||||
assert "test/connected2" in permalinks
|
||||
assert "test/connected-entity-1" in permalinks
|
||||
assert "test/connected-entity-2" in permalinks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -68,17 +71,27 @@ async def test_search_title(search_service, test_graph):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_search_features(search_service, test_graph):
|
||||
async def test_text_search_case_insensitive(search_service, test_graph):
|
||||
"""Test text search functionality."""
|
||||
# Case insensitive
|
||||
results = await search_service.search(SearchQuery(text="ENTITY"))
|
||||
assert any("test/root" in r.permalink for r in results)
|
||||
|
||||
# Partial word match
|
||||
results = await search_service.search(SearchQuery(text="Connect"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_search_content_word_match(search_service, test_graph):
|
||||
"""Test text search functionality."""
|
||||
|
||||
# content word match
|
||||
results = await search_service.search(SearchQuery(text="Connected"))
|
||||
assert len(results) > 0
|
||||
assert any(r.file_path == "test/Connected Entity 2.md" for r in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_search_multiple_terms(search_service, test_graph):
|
||||
"""Test text search functionality."""
|
||||
|
||||
# Multiple terms
|
||||
results = await search_service.search(SearchQuery(text="root note"))
|
||||
assert any("test/root" in r.permalink for r in results)
|
||||
@@ -88,15 +101,20 @@ async def test_text_search_features(search_service, test_graph):
|
||||
async def test_pattern_matching(search_service, test_graph):
|
||||
"""Test pattern matching with various wildcards."""
|
||||
# Test wildcards
|
||||
results = await search_service.search(SearchQuery(permalink="test/*"))
|
||||
results = await search_service.search(SearchQuery(permalink_match="test/*"))
|
||||
for r in results:
|
||||
assert "test/" in r.permalink
|
||||
|
||||
# Test start wildcards
|
||||
results = await search_service.search(SearchQuery(permalink="*/observations"))
|
||||
results = await search_service.search(SearchQuery(permalink_match="*/observations"))
|
||||
for r in results:
|
||||
assert "/observations" in r.permalink
|
||||
|
||||
# Test permalink partial match
|
||||
results = await search_service.search(SearchQuery(permalink_match="test"))
|
||||
for r in results:
|
||||
assert "test/" in r.permalink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters(search_service, test_graph):
|
||||
|
||||
Reference in New Issue
Block a user