Compare commits

..

9 Commits

Author SHA1 Message Date
semantic-release 16466e9269 chore(release): 0.4.3 [skip ci] 2025-02-18 00:49:28 +00:00
Paul Hernandez 39bd5ca08f fix: Re do enhanced read note format (#10)
Co-authored-by: phernandez <phernandez@basicmachines.co>
2025-02-17 18:47:59 -06:00
semantic-release 4a1f5452c4 chore(release): 0.4.2 [skip ci] 2025-02-17 03:54:57 +00:00
phernandez 9c9960e2ca Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-16 21:49:51 -06:00
phernandez 30cd74ec95 fix: more alembic fixes 2025-02-16 21:49:41 -06:00
semantic-release 132c5671d8 chore(release): 0.4.1 [skip ci] 2025-02-17 03:29:36 +00:00
phernandez 71de8acfd0 fix: fix alemic config 2025-02-16 21:22:22 -06:00
phernandez 50469d691b Merge branch 'main' of github.com:basicmachines-co/basic-memory 2025-02-16 21:13:44 -06:00
phernandez 85e620c72b rejigger alembic files 2025-02-16 21:13:29 -06:00
20 changed files with 513 additions and 256 deletions
+24
View File
@@ -1,6 +1,30 @@
# CHANGELOG
## 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
- 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)
### Features
+1 -1
View File
@@ -266,7 +266,7 @@ including
Import Claude.ai conversation data
```bash
basic-memory import claude conversations conversations.json
basic-memory import claude conversations
```
The conversations will be turned into Markdown files and placed in the "conversations" folder by default (this can be
-119
View File
@@ -1,119 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = src/basic_memory/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "basic-memory"
version = "0.4.0"
version = "0.4.3"
description = "Local-first knowledge management combining Zettelkasten with knowledge graphs"
readme = "README.md"
requires-python = ">=3.12.1"
+1 -1
View File
@@ -1,3 +1,3 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
__version__ = "0.4.0"
__version__ = "0.4.3"
+96 -12
View File
@@ -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 _:
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."""
+16 -1
View File
@@ -134,7 +134,22 @@ async def run_migrations(app_config: ProjectConfig, database_type=DatabaseType.F
"""Run any pending alembic migrations."""
logger.info("Running database migrations...")
try:
config = Config("alembic.ini")
# 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(
"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")
+85 -20
View File
@@ -28,9 +28,29 @@ async def write_note(
) -> EntityResponse | 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
@@ -40,19 +60,32 @@ async def write_note(
If verbose=True: EntityResponse with full semantic details
Examples:
# Create a simple note
# Note with both explicit and inline relations
write_note(
tile="Meeting Notes: Project Planning.md",
content="# Key Points\\n\\n- Discussed timeline\\n- Set priorities"
folder="notes"
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"
)
# Create note with tags
# 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="Error Handling Design",
content="# Error Handling\\n\\n"
"This design builds on [[Reliability Design]].\\n\\n"
"## Approach\\n"
"- [design] Use error codes #architecture\\n"
"- [tech] Implement retry logic #implementation\\n\\n"
"## Relations\\n"
"- extends [[Base Error Handling]]",
folder="docs/design",
tags=["architecture", "reliability"]
)
"""
logger.info(f"Writing note folder:'{folder}' title: '{title}'")
@@ -76,26 +109,58 @@ async def write_note(
return result if verbose else result.permalink
@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):
+8
View File
@@ -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}")
+8 -7
View File
@@ -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:
+1 -1
View File
@@ -75,7 +75,7 @@ class SearchService:
else None
)
# permalink search
# search
results = await self.repository.search(
search_text=query.text,
permalink=query.permalink,
+139 -1
View File
@@ -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,139 @@ 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
relation = entity2.relations[0]
# 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
)
-21
View File
@@ -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,
+19 -7
View File
@@ -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"
+26 -29
View File
@@ -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"]
+29 -11
View File
@@ -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):
Generated
+1 -1
View File
@@ -70,7 +70,7 @@ wheels = [
[[package]]
name = "basic-memory"
version = "0.3.0"
version = "0.4.2"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },