mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: fast edit entities, refactors for webui, enhance search (#532)
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -396,6 +396,8 @@ list_directory(dir_name, depth) - Browse directory contents with filtering
|
||||
**Search & Discovery:**
|
||||
```
|
||||
search(query, page, page_size) - Search across your knowledge base
|
||||
search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project) - Search with filters
|
||||
search_by_metadata(filters, limit, offset, project) - Structured frontmatter search
|
||||
```
|
||||
|
||||
**Project Management:**
|
||||
|
||||
+15
-2
@@ -214,15 +214,28 @@ Example tool using typed client:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def search_notes(query: str, project: str | None = None) -> SearchResponse:
|
||||
async def search_notes(
|
||||
query: str,
|
||||
project: str | None = None,
|
||||
metadata_filters: dict | None = None,
|
||||
tags: list[str] | None = None,
|
||||
status: str | None = None,
|
||||
) -> SearchResponse:
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project)
|
||||
|
||||
# Import client inside function to avoid circular imports
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
from basic_memory.schemas.search import SearchQuery
|
||||
|
||||
search_query = SearchQuery(
|
||||
text=query,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
status=status,
|
||||
)
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
return await search_client.search(query)
|
||||
return await search_client.search(search_query.model_dump())
|
||||
```
|
||||
|
||||
## Sync Coordination
|
||||
|
||||
@@ -1038,6 +1038,35 @@ recent_decisions = await search_notes(
|
||||
)
|
||||
```
|
||||
|
||||
**Structured frontmatter filters**:
|
||||
|
||||
```python
|
||||
# Filter by tags and status
|
||||
results = await search_notes(
|
||||
query="authentication",
|
||||
tags=["security"],
|
||||
status="in-progress",
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Complex metadata filters (supports $in, $gt, $gte, $lt, $lte, $between)
|
||||
results = await search_notes(
|
||||
query="api design",
|
||||
metadata_filters={
|
||||
"type": "spec",
|
||||
"priority": {"$in": ["high", "critical"]},
|
||||
"tags": ["architecture"]
|
||||
},
|
||||
project="main"
|
||||
)
|
||||
|
||||
# Metadata-only search
|
||||
results = await search_by_metadata(
|
||||
filters={"type": "spec", "status": "in-progress"},
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Search Types
|
||||
|
||||
**Text search (default)**:
|
||||
@@ -2861,7 +2890,7 @@ contents = await list_directory(
|
||||
|
||||
### Search & Discovery
|
||||
|
||||
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, project)**
|
||||
**search_notes(query, page, page_size, search_type, types, entity_types, after_date, metadata_filters, tags, status, project)**
|
||||
- Search across knowledge base
|
||||
- Parameters:
|
||||
- `query` (required): Search query
|
||||
@@ -2871,6 +2900,9 @@ contents = await list_directory(
|
||||
- `types` (optional): Entity type filter
|
||||
- `entity_types` (optional): Observation category filter
|
||||
- `after_date` (optional): Date filter (ISO format)
|
||||
- `metadata_filters` (optional): Structured frontmatter filters (dict)
|
||||
- `tags` (optional): Frontmatter tags filter (list)
|
||||
- `status` (optional): Frontmatter status filter (string)
|
||||
- `project` (required unless default_project_mode): Target project
|
||||
- Returns: Matching entities with scores
|
||||
- Example:
|
||||
@@ -2883,6 +2915,22 @@ results = await search_notes(
|
||||
)
|
||||
```
|
||||
|
||||
**search_by_metadata(filters, limit, offset, project)**
|
||||
- Metadata-only search using structured frontmatter
|
||||
- Parameters:
|
||||
- `filters` (required): Dict of field -> value (supports $in, $gt/$gte/$lt/$lte, $between)
|
||||
- `limit` (optional): Max results (default: 20)
|
||||
- `offset` (optional): Pagination offset (default: 0)
|
||||
- `project` (required unless default_project_mode): Target project
|
||||
- Returns: Matching entities
|
||||
- Example:
|
||||
```python
|
||||
results = await search_by_metadata(
|
||||
filters={"type": "spec", "status": "in-progress"},
|
||||
project="main"
|
||||
)
|
||||
```
|
||||
|
||||
### Project Management
|
||||
|
||||
**list_memory_projects()**
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Add structured metadata indexes for entity frontmatter
|
||||
|
||||
Revision ID: d7e8f9a0b1c2
|
||||
Revises: g9a0b3c4d5e6
|
||||
Create Date: 2026-01-31 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d7e8f9a0b1c2"
|
||||
down_revision: Union[str, None] = "6830751f5fb6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add JSONB/GiN indexes for Postgres and generated columns for SQLite."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
# Ensure JSONB for efficient indexing
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_name = 'entity' AND column_name = 'entity_metadata'"
|
||||
)
|
||||
).fetchone()
|
||||
if result and result[0] != "jsonb":
|
||||
op.execute(
|
||||
"ALTER TABLE entity ALTER COLUMN entity_metadata "
|
||||
"TYPE jsonb USING entity_metadata::jsonb"
|
||||
)
|
||||
|
||||
# General JSONB GIN index
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_metadata_gin "
|
||||
"ON entity USING GIN (entity_metadata jsonb_path_ops)"
|
||||
)
|
||||
|
||||
# Common field indexes
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_tags_json "
|
||||
"ON entity USING GIN ((entity_metadata -> 'tags'))"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_type "
|
||||
"ON entity ((entity_metadata ->> 'type'))"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_frontmatter_status "
|
||||
"ON entity ((entity_metadata ->> 'status'))"
|
||||
)
|
||||
return
|
||||
|
||||
# SQLite: add generated columns for common frontmatter fields
|
||||
if not column_exists(connection, "entity", "tags_json"):
|
||||
op.add_column(
|
||||
"entity",
|
||||
sa.Column(
|
||||
"tags_json",
|
||||
sa.Text(),
|
||||
sa.Computed("json_extract(entity_metadata, '$.tags')", persisted=True),
|
||||
),
|
||||
)
|
||||
if not column_exists(connection, "entity", "frontmatter_status"):
|
||||
op.add_column(
|
||||
"entity",
|
||||
sa.Column(
|
||||
"frontmatter_status",
|
||||
sa.Text(),
|
||||
sa.Computed("json_extract(entity_metadata, '$.status')", persisted=True),
|
||||
),
|
||||
)
|
||||
if not column_exists(connection, "entity", "frontmatter_type"):
|
||||
op.add_column(
|
||||
"entity",
|
||||
sa.Column(
|
||||
"frontmatter_type",
|
||||
sa.Text(),
|
||||
sa.Computed("json_extract(entity_metadata, '$.type')", persisted=True),
|
||||
),
|
||||
)
|
||||
|
||||
# Index generated columns
|
||||
if not index_exists(connection, "idx_entity_tags_json"):
|
||||
op.create_index("idx_entity_tags_json", "entity", ["tags_json"])
|
||||
if not index_exists(connection, "idx_entity_frontmatter_status"):
|
||||
op.create_index("idx_entity_frontmatter_status", "entity", ["frontmatter_status"])
|
||||
if not index_exists(connection, "idx_entity_frontmatter_type"):
|
||||
op.create_index("idx_entity_frontmatter_type", "entity", ["frontmatter_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Best-effort downgrade (drop indexes, revert JSONB on Postgres)."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_metadata_gin")
|
||||
op.execute(
|
||||
"ALTER TABLE entity ALTER COLUMN entity_metadata TYPE json USING entity_metadata::json"
|
||||
)
|
||||
return
|
||||
|
||||
# SQLite: drop indexes (dropping generated columns requires table rebuild)
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_status")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_frontmatter_type")
|
||||
op.execute("DROP INDEX IF EXISTS idx_entity_tags_json")
|
||||
@@ -8,17 +8,6 @@ from loguru import logger
|
||||
|
||||
from basic_memory import __version__ as version
|
||||
from basic_memory.api.container import ApiContainer, set_container
|
||||
from basic_memory.api.routers import (
|
||||
directory_router,
|
||||
importer_router,
|
||||
knowledge,
|
||||
management,
|
||||
memory,
|
||||
project,
|
||||
resource,
|
||||
search,
|
||||
prompt_router,
|
||||
)
|
||||
from basic_memory.api.v2.routers import (
|
||||
knowledge_router as v2_knowledge,
|
||||
project_router as v2_project,
|
||||
@@ -90,19 +79,10 @@ app.include_router(v2_prompt, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_importer, prefix="/v2/projects/{project_id}")
|
||||
app.include_router(v2_project, prefix="/v2")
|
||||
|
||||
# Include v1 routers (/{project} is a catch-all, must come after specific prefixes)
|
||||
app.include_router(knowledge.router, prefix="/{project}")
|
||||
app.include_router(memory.router, prefix="/{project}")
|
||||
app.include_router(resource.router, prefix="/{project}")
|
||||
app.include_router(search.router, prefix="/{project}")
|
||||
app.include_router(project.project_router, prefix="/{project}")
|
||||
app.include_router(directory_router.router, prefix="/{project}")
|
||||
app.include_router(prompt_router.router, prefix="/{project}")
|
||||
app.include_router(importer_router.router, prefix="/{project}")
|
||||
# Legacy web app proxy paths (compat with /proxy/projects/projects)
|
||||
app.include_router(v2_project, prefix="/proxy/projects")
|
||||
|
||||
# Project resource router works across projects
|
||||
app.include_router(project.project_resource_router)
|
||||
app.include_router(management.router)
|
||||
# V2 routers are the only public API surface
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""API routers."""
|
||||
|
||||
from . import knowledge_router as knowledge
|
||||
from . import management_router as management
|
||||
from . import memory_router as memory
|
||||
from . import project_router as project
|
||||
from . import resource_router as resource
|
||||
from . import search_router as search
|
||||
from . import prompt_router as prompt
|
||||
|
||||
__all__ = ["knowledge", "management", "memory", "project", "resource", "search", "prompt"]
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Router for directory tree operations."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from basic_memory.deps import DirectoryServiceDep, ProjectIdDep
|
||||
from basic_memory.schemas.directory import DirectoryNode
|
||||
|
||||
router = APIRouter(prefix="/directory", tags=["directory"])
|
||||
|
||||
|
||||
@router.get("/tree", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_tree(
|
||||
directory_service: DirectoryServiceDep,
|
||||
project_id: ProjectIdDep,
|
||||
):
|
||||
"""Get hierarchical directory structure from the knowledge base.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: ID of the current project
|
||||
|
||||
Returns:
|
||||
DirectoryNode representing the root of the hierarchical tree structure
|
||||
"""
|
||||
# Get a hierarchical directory tree for the specific project
|
||||
tree = await directory_service.get_directory_tree()
|
||||
|
||||
# Return the hierarchical tree
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("/structure", response_model=DirectoryNode, response_model_exclude_none=True)
|
||||
async def get_directory_structure(
|
||||
directory_service: DirectoryServiceDep,
|
||||
project_id: ProjectIdDep,
|
||||
):
|
||||
"""Get folder structure for navigation (no files).
|
||||
|
||||
Optimized endpoint for folder tree navigation. Returns only directory nodes
|
||||
without file metadata. For full tree with files, use /directory/tree.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: ID of the current project
|
||||
|
||||
Returns:
|
||||
DirectoryNode tree containing only folders (type="directory")
|
||||
"""
|
||||
structure = await directory_service.get_directory_structure()
|
||||
return structure
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DirectoryNode], response_model_exclude_none=True)
|
||||
async def list_directory(
|
||||
directory_service: DirectoryServiceDep,
|
||||
project_id: ProjectIdDep,
|
||||
dir_name: str = Query("/", description="Directory path to list"),
|
||||
depth: int = Query(1, ge=1, le=10, description="Recursion depth (1-10)"),
|
||||
file_name_glob: Optional[str] = Query(
|
||||
None, description="Glob pattern for filtering file names"
|
||||
),
|
||||
):
|
||||
"""List directory contents with filtering and depth control.
|
||||
|
||||
Args:
|
||||
directory_service: Service for directory operations
|
||||
project_id: ID of the current project
|
||||
dir_name: Directory path to list (default: root "/")
|
||||
depth: Recursion depth (1-10, default: 1 for immediate children only)
|
||||
file_name_glob: Optional glob pattern for filtering file names (e.g., "*.md", "*meeting*")
|
||||
|
||||
Returns:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Get directory listing with filtering
|
||||
nodes = await directory_service.list_directory(
|
||||
dir_name=dir_name,
|
||||
depth=depth,
|
||||
file_name_glob=file_name_glob,
|
||||
)
|
||||
|
||||
return nodes
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Import router for Basic Memory API."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, UploadFile, status
|
||||
|
||||
from basic_memory.deps import (
|
||||
ChatGPTImporterDep,
|
||||
ClaudeConversationsImporterDep,
|
||||
ClaudeProjectsImporterDep,
|
||||
MemoryJsonImporterDep,
|
||||
)
|
||||
from basic_memory.importers import Importer
|
||||
from basic_memory.schemas.importer import (
|
||||
ChatImportResult,
|
||||
EntityImportResult,
|
||||
ProjectImportResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/import", tags=["import"])
|
||||
|
||||
|
||||
@router.post("/chatgpt", response_model=ChatImportResult)
|
||||
async def import_chatgpt(
|
||||
importer: ChatGPTImporterDep,
|
||||
file: UploadFile,
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
file: The ChatGPT conversations.json file.
|
||||
directory: The directory to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
async def import_claude_conversations(
|
||||
importer: ClaudeConversationsImporterDep,
|
||||
file: UploadFile,
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
file: The Claude conversations.json file.
|
||||
directory: The directory to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
ChatImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
async def import_claude_projects(
|
||||
importer: ClaudeProjectsImporterDep,
|
||||
file: UploadFile,
|
||||
directory: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
file: The Claude projects.json file.
|
||||
directory: The directory to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
ProjectImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
async def import_memory_json(
|
||||
importer: MemoryJsonImporterDep,
|
||||
file: UploadFile,
|
||||
directory: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
file: The memory.json file.
|
||||
directory: Optional destination directory within the project.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
EntityImportResult with import statistics.
|
||||
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
try:
|
||||
file_data = []
|
||||
file_bytes = await file.read()
|
||||
file_str = file_bytes.decode("utf-8")
|
||||
for line in file_str.splitlines():
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
|
||||
result = await importer.import_data(file_data, directory)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
|
||||
try:
|
||||
# Process file
|
||||
json_data = json.load(file.file)
|
||||
result = await importer.import_data(json_data, destination_folder)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=result.error_message or "Import failed",
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Import failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Import failed: {str(e)}",
|
||||
)
|
||||
@@ -1,364 +0,0 @@
|
||||
"""Router for knowledge graph operations.
|
||||
|
||||
⚠️ DEPRECATED: This v1 API is deprecated and will be removed on June 30, 2026.
|
||||
Please migrate to /v2/{project}/knowledge endpoints which use entity IDs instead
|
||||
of path-based identifiers for improved performance and stability.
|
||||
|
||||
Migration guide: See docs/migration/v1-to-v2.md
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Query, Response
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
EntityServiceDep,
|
||||
get_search_service,
|
||||
SearchServiceDep,
|
||||
LinkResolverDep,
|
||||
ProjectPathDep,
|
||||
FileServiceDep,
|
||||
ProjectConfigDep,
|
||||
AppConfigDep,
|
||||
SyncServiceDep,
|
||||
)
|
||||
from basic_memory.schemas import (
|
||||
EntityListResponse,
|
||||
EntityResponse,
|
||||
DeleteEntitiesResponse,
|
||||
DeleteEntitiesRequest,
|
||||
)
|
||||
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest, MoveDirectoryRequest
|
||||
from basic_memory.schemas.response import DirectoryMoveResult
|
||||
from basic_memory.schemas.base import Permalink, Entity
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/knowledge",
|
||||
tags=["knowledge"],
|
||||
deprecated=True, # Marks entire router as deprecated in OpenAPI docs
|
||||
)
|
||||
|
||||
|
||||
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
||||
"""Background task to resolve relations for a specific entity.
|
||||
|
||||
This runs asynchronously after the API response is sent, preventing
|
||||
long delays when creating entities with many relations.
|
||||
"""
|
||||
try:
|
||||
# Only resolve relations for the newly created entity
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
logger.debug(
|
||||
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
# Log but don't fail - this is a background task.
|
||||
# Avoid forcing synthetic failures just for coverage.
|
||||
logger.warning( # pragma: no cover
|
||||
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
||||
)
|
||||
|
||||
|
||||
## Create endpoints
|
||||
|
||||
|
||||
@router.post("/entities", response_model=EntityResponse)
|
||||
async def create_entity(
|
||||
data: Entity,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Create an entity."""
|
||||
logger.info(
|
||||
"API request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
||||
)
|
||||
|
||||
entity = await entity_service.create_entity(data)
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API response: endpoint='create_entity' title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/entities/{permalink:path}", response_model=EntityResponse)
|
||||
async def create_or_update_entity(
|
||||
project: ProjectPathDep,
|
||||
permalink: Permalink,
|
||||
data: Entity,
|
||||
response: Response,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
sync_service: SyncServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
|
||||
logger.info(
|
||||
f"API request: create_or_update_entity for {project=}, {permalink=}, {data.entity_type=}, {data.title=}"
|
||||
)
|
||||
|
||||
# Validate permalink matches
|
||||
if data.permalink != permalink:
|
||||
logger.warning(
|
||||
f"API validation error: creating/updating entity with permalink mismatch - url={permalink}, data={data.permalink}",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Entity permalink {data.permalink} must match URL path: '{permalink}'",
|
||||
)
|
||||
|
||||
# Try create_or_update operation
|
||||
entity, created = await entity_service.create_or_update_entity(data)
|
||||
response.status_code = 201 if created else 200
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Schedule relation resolution as a background task for new entities
|
||||
# This prevents blocking the API response while resolving potentially many relations
|
||||
if created:
|
||||
background_tasks.add_task(
|
||||
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
|
||||
)
|
||||
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
f"API response: {result.title=}, {result.permalink=}, {created=}, status_code={response.status_code}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/entities/{identifier:path}", response_model=EntityResponse)
|
||||
async def edit_entity(
|
||||
identifier: str,
|
||||
data: EditEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Edit an existing entity using various operations like append, prepend, find_replace, or replace_section.
|
||||
|
||||
This endpoint allows for targeted edits without requiring the full entity content.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='edit_entity', identifier='{identifier}', operation='{data.operation}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Edit the entity using the service
|
||||
entity = await entity_service.edit_entity(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
|
||||
# Reindex the updated entity
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Return the updated entity response
|
||||
result = EntityResponse.model_validate(entity)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="edit_entity",
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
permalink=result.permalink,
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move_entity(
|
||||
data: MoveEntityRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
project_config: ProjectConfigDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> EntityResponse:
|
||||
"""Move an entity to a new file location with project consistency.
|
||||
|
||||
This endpoint moves a note to a different path while maintaining project
|
||||
consistency and optionally updating permalinks based on configuration.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='move_entity', identifier='{data.identifier}', destination='{data.destination_path}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the entity using the service
|
||||
moved_entity = await entity_service.move_entity(
|
||||
identifier=data.identifier,
|
||||
destination_path=data.destination_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Get the moved entity to reindex it
|
||||
entity = await entity_service.link_resolver.resolve_link(data.destination_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
logger.info(
|
||||
"API response",
|
||||
endpoint="move_entity",
|
||||
identifier=data.identifier,
|
||||
destination=data.destination_path,
|
||||
status_code=200,
|
||||
)
|
||||
result = EntityResponse.model_validate(moved_entity)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/move-directory")
|
||||
async def move_directory(
|
||||
data: MoveDirectoryRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
project_config: ProjectConfigDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> DirectoryMoveResult:
|
||||
"""Move all entities in a directory to a new location.
|
||||
|
||||
This endpoint moves all files within a source directory to a destination
|
||||
directory, updating database records and optionally updating permalinks.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='move_directory', source='{data.source_directory}', destination='{data.destination_directory}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the directory using the service
|
||||
result = await entity_service.move_directory(
|
||||
source_directory=data.source_directory,
|
||||
destination_directory=data.destination_directory,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex moved entities
|
||||
for file_path in result.moved_files:
|
||||
entity = await entity_service.link_resolver.resolve_link(file_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
logger.info(
|
||||
f"API response: endpoint='move_directory', "
|
||||
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Read endpoints
|
||||
|
||||
|
||||
@router.get("/entities/{identifier:path}", response_model=EntityResponse)
|
||||
async def get_entity(
|
||||
entity_service: EntityServiceDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
identifier: str,
|
||||
) -> EntityResponse:
|
||||
"""Get a specific entity by file path or permalink..
|
||||
|
||||
Args:
|
||||
identifier: Entity file path or permalink
|
||||
:param entity_service: EntityService
|
||||
:param link_resolver: LinkResolver
|
||||
"""
|
||||
logger.info(f"request: get_entity with identifier={identifier}")
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"Entity {identifier} not found")
|
||||
|
||||
result = EntityResponse.model_validate(entity)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/entities", response_model=EntityListResponse)
|
||||
async def get_entities(
|
||||
entity_service: EntityServiceDep,
|
||||
permalink: Annotated[list[str] | None, Query()] = None,
|
||||
) -> EntityListResponse:
|
||||
"""Open specific entities"""
|
||||
logger.info(f"request: get_entities with permalinks={permalink}")
|
||||
|
||||
entities = await entity_service.get_entities_by_permalinks(permalink) if permalink else []
|
||||
result = EntityListResponse(
|
||||
entities=[EntityResponse.model_validate(entity) for entity in entities]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
## Delete endpoints
|
||||
|
||||
|
||||
@router.delete("/entities/{identifier:path}", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entity(
|
||||
identifier: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
search_service=Depends(get_search_service),
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete a single entity and remove from search index."""
|
||||
logger.info(f"request: delete_entity with identifier={identifier}")
|
||||
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
if entity is None:
|
||||
return DeleteEntitiesResponse(deleted=False)
|
||||
|
||||
# Delete the entity
|
||||
deleted = await entity_service.delete_entity(entity.permalink or entity.id)
|
||||
|
||||
# Remove from search index (entity, observations, and relations)
|
||||
background_tasks.add_task(search_service.handle_delete, entity)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/entities/delete", response_model=DeleteEntitiesResponse)
|
||||
async def delete_entities(
|
||||
data: DeleteEntitiesRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
search_service=Depends(get_search_service),
|
||||
) -> DeleteEntitiesResponse:
|
||||
"""Delete entities and remove from search index."""
|
||||
logger.info(f"request: delete_entities with data={data}")
|
||||
deleted = False
|
||||
|
||||
# Remove each deleted entity from search index
|
||||
for permalink in data.permalinks:
|
||||
deleted = await entity_service.delete_entity(permalink)
|
||||
background_tasks.add_task(search_service.delete_by_permalink, permalink)
|
||||
|
||||
result = DeleteEntitiesResponse(deleted=deleted)
|
||||
return result
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Management router for basic-memory API."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.deps import SyncServiceDep, ProjectRepositoryDep
|
||||
|
||||
router = APIRouter(prefix="/management", tags=["management"])
|
||||
|
||||
|
||||
class WatchStatusResponse(BaseModel):
|
||||
"""Response model for watch status."""
|
||||
|
||||
running: bool
|
||||
"""Whether the watch service is currently running."""
|
||||
|
||||
|
||||
@router.get("/watch/status", response_model=WatchStatusResponse)
|
||||
async def get_watch_status(request: Request) -> WatchStatusResponse:
|
||||
"""Get the current status of the watch service."""
|
||||
return WatchStatusResponse(
|
||||
running=request.app.state.watch_task is not None and not request.app.state.watch_task.done()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/watch/start", response_model=WatchStatusResponse)
|
||||
async def start_watch_service(
|
||||
request: Request, project_repository: ProjectRepositoryDep, sync_service: SyncServiceDep
|
||||
) -> WatchStatusResponse:
|
||||
"""Start the watch service if it's not already running."""
|
||||
|
||||
# needed because of circular imports from sync -> app
|
||||
from basic_memory.sync import WatchService
|
||||
from basic_memory.sync.background_sync import create_background_sync_task
|
||||
|
||||
if request.app.state.watch_task is not None and not request.app.state.watch_task.done():
|
||||
# Watch service is already running
|
||||
return WatchStatusResponse(running=True)
|
||||
|
||||
app_config = ConfigManager().config
|
||||
|
||||
# Create and start a new watch service
|
||||
logger.info("Starting watch service via management API")
|
||||
|
||||
# Get services needed for the watch task
|
||||
watch_service = WatchService(
|
||||
app_config=app_config,
|
||||
project_repository=project_repository,
|
||||
)
|
||||
|
||||
# Create and store the task
|
||||
watch_task = create_background_sync_task(sync_service, watch_service)
|
||||
request.app.state.watch_task = watch_task
|
||||
|
||||
return WatchStatusResponse(running=True)
|
||||
|
||||
|
||||
@router.post("/watch/stop", response_model=WatchStatusResponse)
|
||||
async def stop_watch_service(request: Request) -> WatchStatusResponse: # pragma: no cover
|
||||
"""Stop the watch service if it's running."""
|
||||
if request.app.state.watch_task is None or request.app.state.watch_task.done():
|
||||
# Watch service is not running
|
||||
return WatchStatusResponse(running=False)
|
||||
|
||||
# Cancel the running task
|
||||
logger.info("Stopping watch service via management API")
|
||||
request.app.state.watch_task.cancel()
|
||||
|
||||
# Wait for it to be properly cancelled
|
||||
try:
|
||||
await request.app.state.watch_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
request.app.state.watch_task = None
|
||||
return WatchStatusResponse(running=False)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Routes for memory:// URI operations."""
|
||||
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
|
||||
from basic_memory.schemas.base import TimeFrame, parse_timeframe
|
||||
from basic_memory.schemas.memory import (
|
||||
GraphContext,
|
||||
normalize_memory_url,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.api.routers.utils import to_graph_context
|
||||
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
@router.get("/recent", response_model=GraphContext)
|
||||
async def recent(
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
type: Annotated[list[SearchItemType] | None, Query()] = None,
|
||||
depth: int = 1,
|
||||
timeframe: TimeFrame = "7d",
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
# return all types by default
|
||||
types = (
|
||||
[SearchItemType.ENTITY, SearchItemType.RELATION, SearchItemType.OBSERVATION]
|
||||
if not type
|
||||
else type
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe)
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
recent_context = await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
logger.debug(f"Recent context: {recent_context.model_dump_json()}")
|
||||
return recent_context
|
||||
|
||||
|
||||
# get_memory_context needs to be declared last so other paths can match
|
||||
|
||||
|
||||
@router.get("/{uri:path}", response_model=GraphContext)
|
||||
async def get_memory_context(
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
uri: str,
|
||||
depth: int = 1,
|
||||
timeframe: Optional[TimeFrame] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
) -> GraphContext:
|
||||
"""Get rich context from memory:// URI."""
|
||||
# add the project name from the config to the url as the "host
|
||||
# Parse URI
|
||||
logger.debug(
|
||||
f"Getting context for URI: `{uri}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
|
||||
)
|
||||
memory_url = normalize_memory_url(uri)
|
||||
|
||||
# Parse timeframe
|
||||
since = parse_timeframe(timeframe) if timeframe else None
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# Build context
|
||||
context = await context_service.build_context(
|
||||
memory_url, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
|
||||
)
|
||||
return await to_graph_context(
|
||||
context, entity_repository=entity_repository, page=page, page_size=page_size
|
||||
)
|
||||
@@ -1,472 +0,0 @@
|
||||
"""Router for project management."""
|
||||
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigDep,
|
||||
ProjectServiceDep,
|
||||
ProjectPathDep,
|
||||
SyncServiceDep,
|
||||
)
|
||||
from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectList,
|
||||
ProjectItem,
|
||||
ProjectInfoRequest,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
from basic_memory.utils import normalize_project_path
|
||||
|
||||
# Router for resources in a specific project
|
||||
# The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
|
||||
project_router = APIRouter(prefix="/project", tags=["project"])
|
||||
|
||||
# Router for managing project resources
|
||||
project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
|
||||
|
||||
|
||||
@project_router.get("/info", response_model=ProjectInfoResponse)
|
||||
async def get_project_info(
|
||||
project_service: ProjectServiceDep,
|
||||
project: ProjectPathDep,
|
||||
) -> ProjectInfoResponse:
|
||||
"""Get comprehensive information about the specified Basic Memory project."""
|
||||
return await project_service.get_project_info(project)
|
||||
|
||||
|
||||
@project_router.get("/item", response_model=ProjectItem)
|
||||
async def get_project(
|
||||
project_service: ProjectServiceDep,
|
||||
project: ProjectPathDep,
|
||||
) -> ProjectItem:
|
||||
"""Get bassic info about the specified Basic Memory project."""
|
||||
found_project = await project_service.get_project(project)
|
||||
if not found_project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project: '{project}' does not exist"
|
||||
) # pragma: no cover
|
||||
|
||||
return ProjectItem(
|
||||
id=found_project.id,
|
||||
external_id=found_project.external_id,
|
||||
name=found_project.name,
|
||||
path=normalize_project_path(found_project.path),
|
||||
is_default=found_project.is_default or False,
|
||||
)
|
||||
|
||||
|
||||
# Update a project
|
||||
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
|
||||
async def update_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to update"),
|
||||
path: Optional[str] = Body(None, description="New absolute path for the project"),
|
||||
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Update a project's information in configuration and database.
|
||||
|
||||
Args:
|
||||
name: The name of the project to update
|
||||
path: Optional new absolute path for the project
|
||||
is_active: Optional status update for the project
|
||||
|
||||
Returns:
|
||||
Response confirming the project was updated
|
||||
"""
|
||||
try:
|
||||
# Validate that path is absolute if provided
|
||||
if path and not os.path.isabs(path):
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
# Get original project info for the response
|
||||
old_project = await project_service.get_project(name)
|
||||
if not old_project:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Project '{name}' not found in configuration"
|
||||
)
|
||||
|
||||
old_project_info = ProjectItem(
|
||||
id=old_project.id,
|
||||
external_id=old_project.external_id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
)
|
||||
|
||||
if path:
|
||||
await project_service.move_project(name, path)
|
||||
elif is_active is not None:
|
||||
await project_service.update_project(name, is_active=is_active)
|
||||
|
||||
# Get updated project info
|
||||
updated_project = await project_service.get_project(name)
|
||||
if not updated_project:
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail=f"Project '{name}' not found after update"
|
||||
)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' updated successfully",
|
||||
status="success",
|
||||
default=(name == project_service.default_project),
|
||||
old_project=old_project_info,
|
||||
new_project=ProjectItem(
|
||||
id=updated_project.id,
|
||||
external_id=updated_project.external_id,
|
||||
name=updated_project.name,
|
||||
path=updated_project.path,
|
||||
is_default=updated_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
|
||||
|
||||
|
||||
# Sync project filesystem
|
||||
@project_router.post("/sync")
|
||||
async def sync_project(
|
||||
background_tasks: BackgroundTasks,
|
||||
sync_service: SyncServiceDep,
|
||||
project_config: ProjectConfigDep,
|
||||
force_full: bool = Query(
|
||||
False, description="Force full scan, bypassing watermark optimization"
|
||||
),
|
||||
run_in_background: bool = Query(True, description="Run in background"),
|
||||
):
|
||||
"""Force project filesystem sync to database.
|
||||
|
||||
Scans the project directory and updates the database with any new or modified files.
|
||||
|
||||
Args:
|
||||
background_tasks: FastAPI background tasks
|
||||
sync_service: Sync service for this project
|
||||
project_config: Project configuration
|
||||
force_full: If True, force a full scan even if watermark exists
|
||||
run_in_background: If True, run sync in background and return immediately
|
||||
|
||||
Returns:
|
||||
Response confirming sync was initiated (background) or SyncReportResponse (foreground)
|
||||
"""
|
||||
if run_in_background:
|
||||
background_tasks.add_task(
|
||||
sync_service.sync, project_config.home, project_config.name, force_full=force_full
|
||||
)
|
||||
logger.info(
|
||||
f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "sync_started",
|
||||
"message": f"Filesystem sync initiated for project '{project_config.name}'",
|
||||
}
|
||||
else:
|
||||
report = await sync_service.sync(
|
||||
project_config.home, project_config.name, force_full=force_full
|
||||
)
|
||||
logger.info(
|
||||
f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
|
||||
)
|
||||
return SyncReportResponse.from_sync_report(report)
|
||||
|
||||
|
||||
@project_router.post("/status", response_model=SyncReportResponse)
|
||||
async def project_sync_status(
|
||||
sync_service: SyncServiceDep,
|
||||
project_config: ProjectConfigDep,
|
||||
) -> SyncReportResponse:
|
||||
"""Scan directory for changes compared to database state.
|
||||
|
||||
Args:
|
||||
sync_service: Sync service for this project
|
||||
project_config: Project configuration
|
||||
|
||||
Returns:
|
||||
Scan report with details on files that need syncing
|
||||
"""
|
||||
logger.info(f"Scanning filesystem for project: {project_config.name}") # pragma: no cover
|
||||
sync_report = await sync_service.scan(project_config.home) # pragma: no cover
|
||||
|
||||
return SyncReportResponse.from_sync_report(sync_report) # pragma: no cover
|
||||
|
||||
|
||||
# List all available projects
|
||||
@project_resource_router.get("/projects", response_model=ProjectList)
|
||||
async def list_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectList:
|
||||
"""List all configured projects.
|
||||
|
||||
Returns:
|
||||
A list of all projects with metadata
|
||||
"""
|
||||
projects = await project_service.list_projects()
|
||||
default_project = project_service.default_project
|
||||
|
||||
project_items = [
|
||||
ProjectItem(
|
||||
id=project.id,
|
||||
external_id=project.external_id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
|
||||
return ProjectList(
|
||||
projects=project_items,
|
||||
default_project=default_project,
|
||||
)
|
||||
|
||||
|
||||
# Add a new project
|
||||
@project_resource_router.post("/projects", response_model=ProjectStatusResponse, status_code=201)
|
||||
async def add_project(
|
||||
response: Response,
|
||||
project_data: ProjectInfoRequest,
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Add a new project to configuration and database.
|
||||
|
||||
Args:
|
||||
project_data: The project name and path, with option to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was added
|
||||
"""
|
||||
# Check if project already exists before attempting to add
|
||||
existing_project = await project_service.get_project(project_data.name)
|
||||
if existing_project:
|
||||
# Project exists - check if paths match for true idempotency
|
||||
# Normalize paths for comparison (resolve symlinks, etc.)
|
||||
from pathlib import Path
|
||||
|
||||
requested_path = Path(project_data.path).resolve()
|
||||
existing_path = Path(existing_project.path).resolve()
|
||||
|
||||
if requested_path == existing_path:
|
||||
# Same name, same path - return 200 OK (idempotent)
|
||||
response.status_code = 200
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' already exists",
|
||||
status="success",
|
||||
default=existing_project.is_default or False,
|
||||
new_project=ProjectItem(
|
||||
id=existing_project.id,
|
||||
external_id=existing_project.external_id,
|
||||
name=existing_project.name,
|
||||
path=existing_project.path,
|
||||
is_default=existing_project.is_default or False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Same name, different path - this is an error
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Project '{project_data.name}' already exists with different path. Existing: {existing_project.path}, Requested: {project_data.path}",
|
||||
)
|
||||
|
||||
try: # pragma: no cover
|
||||
# The service layer now handles cloud mode validation and path sanitization
|
||||
await project_service.add_project(
|
||||
project_data.name, project_data.path, set_default=project_data.set_default
|
||||
)
|
||||
|
||||
# Fetch the newly created project to get its ID
|
||||
new_project = await project_service.get_project(project_data.name)
|
||||
if not new_project:
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{new_project.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
id=new_project.id,
|
||||
external_id=new_project.external_id,
|
||||
name=new_project.name,
|
||||
path=new_project.path,
|
||||
is_default=new_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Remove a project
|
||||
@project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
|
||||
async def remove_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to remove"),
|
||||
delete_notes: bool = Query(
|
||||
False, description="If True, delete project directory from filesystem"
|
||||
),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Remove a project from configuration and database.
|
||||
|
||||
Args:
|
||||
name: The name of the project to remove
|
||||
delete_notes: If True, delete the project directory from the filesystem
|
||||
|
||||
Returns:
|
||||
Response confirming the project was removed
|
||||
"""
|
||||
try:
|
||||
old_project = await project_service.get_project(name)
|
||||
if not old_project: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project: '{name}' does not exist"
|
||||
) # pragma: no cover
|
||||
|
||||
# Check if trying to delete the default project
|
||||
# In cloud mode, database is source of truth; in local mode, check config
|
||||
config_default = project_service.default_project
|
||||
db_default = await project_service.repository.get_default_project()
|
||||
|
||||
# Use database default if available, otherwise fall back to config default
|
||||
default_project_name = db_default.name if db_default else config_default
|
||||
|
||||
if name == default_project_name:
|
||||
available_projects = await project_service.list_projects()
|
||||
other_projects = [p.name for p in available_projects if p.name != name]
|
||||
detail = f"Cannot delete default project '{name}'. "
|
||||
if other_projects:
|
||||
detail += (
|
||||
f"Set another project as default first. Available: {', '.join(other_projects)}"
|
||||
)
|
||||
else:
|
||||
detail += "This is the only project in your configuration."
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
|
||||
await project_service.remove_project(name, delete_notes=delete_notes)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{old_project.name}' removed successfully",
|
||||
status="success",
|
||||
default=False,
|
||||
old_project=ProjectItem(
|
||||
id=old_project.id,
|
||||
external_id=old_project.external_id,
|
||||
name=old_project.name,
|
||||
path=old_project.path,
|
||||
is_default=old_project.is_default or False,
|
||||
),
|
||||
new_project=None,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Set a project as default
|
||||
@project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
|
||||
async def set_default_project(
|
||||
project_service: ProjectServiceDep,
|
||||
name: str = Path(..., description="Name of the project to set as default"),
|
||||
) -> ProjectStatusResponse:
|
||||
"""Set a project as the default project.
|
||||
|
||||
Args:
|
||||
name: The name of the project to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was set as default
|
||||
"""
|
||||
try:
|
||||
# Get the old default project
|
||||
default_name = project_service.default_project
|
||||
default_project = await project_service.get_project(default_name)
|
||||
if not default_project: # pragma: no cover
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
# get the new project
|
||||
new_default_project = await project_service.get_project(name)
|
||||
if not new_default_project: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project: '{name}' does not exist"
|
||||
) # pragma: no cover
|
||||
|
||||
await project_service.set_default_project(name)
|
||||
|
||||
return ProjectStatusResponse(
|
||||
message=f"Project '{name}' set as default successfully",
|
||||
status="success",
|
||||
default=True,
|
||||
old_project=ProjectItem(
|
||||
id=default_project.id,
|
||||
external_id=default_project.external_id,
|
||||
name=default_name,
|
||||
path=default_project.path,
|
||||
is_default=False,
|
||||
),
|
||||
new_project=ProjectItem(
|
||||
id=new_default_project.id,
|
||||
external_id=new_default_project.external_id,
|
||||
name=name,
|
||||
path=new_default_project.path,
|
||||
is_default=True,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# Get the default project
|
||||
@project_resource_router.get("/default", response_model=ProjectItem)
|
||||
async def get_default_project(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectItem:
|
||||
"""Get the default project.
|
||||
|
||||
Returns:
|
||||
Response with project default information
|
||||
"""
|
||||
# Get the default project
|
||||
# In cloud mode, database is source of truth; in local mode, check config
|
||||
config_default = project_service.default_project
|
||||
db_default = await project_service.repository.get_default_project()
|
||||
|
||||
# Use database default if available, otherwise fall back to config default
|
||||
default_name = db_default.name if db_default else config_default
|
||||
default_project = await project_service.get_project(default_name)
|
||||
if not default_project: # pragma: no cover
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=404, detail=f"Default Project: '{default_name}' does not exist"
|
||||
)
|
||||
|
||||
return ProjectItem(
|
||||
id=default_project.id,
|
||||
external_id=default_project.external_id,
|
||||
name=default_project.name,
|
||||
path=default_project.path,
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
|
||||
# Synchronize projects between config and database
|
||||
@project_resource_router.post("/config/sync", response_model=ProjectStatusResponse)
|
||||
async def synchronize_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Synchronize projects between configuration file and database.
|
||||
|
||||
Ensures that all projects in the configuration file exist in the database
|
||||
and vice versa.
|
||||
|
||||
Returns:
|
||||
Response confirming synchronization was completed
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message="Projects synchronized successfully between configuration and database",
|
||||
status="success",
|
||||
default=False,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -1,260 +0,0 @@
|
||||
"""Router for prompt-related operations.
|
||||
|
||||
This router is responsible for rendering various prompts using Handlebars templates.
|
||||
It centralizes all prompt formatting logic that was previously in the MCP prompts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.template_loader import template_loader
|
||||
from basic_memory.schemas.base import parse_timeframe
|
||||
from basic_memory.deps import (
|
||||
ContextServiceDep,
|
||||
EntityRepositoryDep,
|
||||
SearchServiceDep,
|
||||
EntityServiceDep,
|
||||
)
|
||||
from basic_memory.schemas.prompt import (
|
||||
ContinueConversationRequest,
|
||||
SearchPromptRequest,
|
||||
PromptResponse,
|
||||
PromptMetadata,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType, SearchQuery
|
||||
|
||||
router = APIRouter(prefix="/prompt", tags=["prompt"])
|
||||
|
||||
|
||||
@router.post("/continue-conversation", response_model=PromptResponse)
|
||||
async def continue_conversation(
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
context_service: ContextServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
request: ContinueConversationRequest,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for continuing a conversation.
|
||||
|
||||
This endpoint takes a topic and/or timeframe and generates a prompt with
|
||||
relevant context from the knowledge base.
|
||||
|
||||
Args:
|
||||
request: The request parameters
|
||||
|
||||
Returns:
|
||||
Formatted continuation prompt with context
|
||||
"""
|
||||
logger.info(
|
||||
f"Generating continue conversation prompt, topic: {request.topic}, timeframe: {request.timeframe}"
|
||||
)
|
||||
|
||||
since = parse_timeframe(request.timeframe) if request.timeframe else None
|
||||
|
||||
# Initialize search results
|
||||
search_results = []
|
||||
|
||||
# Get data needed for template
|
||||
if request.topic:
|
||||
query = SearchQuery(text=request.topic, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=request.search_items_limit)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
# Build context from results
|
||||
all_hierarchical_results = []
|
||||
for result in search_results:
|
||||
if hasattr(result, "permalink") and result.permalink:
|
||||
# Get hierarchical context using the new dataclass-based approach
|
||||
context_result = await context_service.build_context(
|
||||
result.permalink,
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True, # Include observations for entities
|
||||
)
|
||||
|
||||
# Process results into the schema format
|
||||
graph_context = await to_graph_context(
|
||||
context_result, entity_repository=entity_repository
|
||||
)
|
||||
|
||||
# Add results to our collection (limit to top results for each permalink)
|
||||
if graph_context.results:
|
||||
all_hierarchical_results.extend(graph_context.results[:3])
|
||||
|
||||
# Limit to a reasonable number of total results
|
||||
all_hierarchical_results = all_hierarchical_results[:10]
|
||||
|
||||
template_context = {
|
||||
"topic": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": all_hierarchical_results,
|
||||
"has_results": len(all_hierarchical_results) > 0,
|
||||
}
|
||||
else:
|
||||
# If no topic, get recent activity
|
||||
context_result = await context_service.build_context(
|
||||
types=[SearchItemType.ENTITY],
|
||||
depth=request.depth,
|
||||
since=since,
|
||||
max_related=request.related_items_limit,
|
||||
include_observations=True,
|
||||
)
|
||||
recent_context = await to_graph_context(context_result, entity_repository=entity_repository)
|
||||
|
||||
hierarchical_results = recent_context.results[:5] # Limit to top 5 recent items
|
||||
|
||||
template_context = {
|
||||
"topic": f"Recent Activity from ({request.timeframe})",
|
||||
"timeframe": request.timeframe,
|
||||
"hierarchical_results": hierarchical_results,
|
||||
"has_results": len(hierarchical_results) > 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render(
|
||||
"prompts/continue_conversation.hbs", template_context
|
||||
)
|
||||
|
||||
# Calculate metadata
|
||||
# Count items of different types
|
||||
observation_count = 0
|
||||
relation_count = 0
|
||||
entity_count = 0
|
||||
|
||||
# Get the hierarchical results from the template context
|
||||
hierarchical_results_for_count = template_context.get("hierarchical_results", [])
|
||||
|
||||
# For topic-based search
|
||||
if request.topic:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
# For recent activity
|
||||
else:
|
||||
for item in hierarchical_results_for_count:
|
||||
if hasattr(item, "observations"):
|
||||
observation_count += len(item.observations) if item.observations else 0
|
||||
|
||||
if hasattr(item, "related_results"):
|
||||
for related in item.related_results or []:
|
||||
if hasattr(related, "type"):
|
||||
if related.type == "relation":
|
||||
relation_count += 1
|
||||
elif related.type == "entity": # pragma: no cover
|
||||
entity_count += 1 # pragma: no cover
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.topic,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results)
|
||||
if request.topic
|
||||
else 0, # Original search results count
|
||||
"context_count": len(hierarchical_results_for_count),
|
||||
"observation_count": observation_count,
|
||||
"relation_count": relation_count,
|
||||
"total_items": (
|
||||
len(hierarchical_results_for_count)
|
||||
+ observation_count
|
||||
+ relation_count
|
||||
+ entity_count
|
||||
),
|
||||
"search_limit": request.search_items_limit,
|
||||
"context_depth": request.depth,
|
||||
"related_limit": request.related_items_limit,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering continue conversation template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search", response_model=PromptResponse)
|
||||
async def search_prompt(
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
request: SearchPromptRequest,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> PromptResponse:
|
||||
"""Generate a prompt for search results.
|
||||
|
||||
This endpoint takes a search query and formats the results into a helpful
|
||||
prompt with context and suggestions.
|
||||
|
||||
Args:
|
||||
request: The search parameters
|
||||
page: The page number for pagination
|
||||
page_size: The number of results per page, defaults to 10
|
||||
|
||||
Returns:
|
||||
Formatted search results prompt with context
|
||||
"""
|
||||
logger.info(f"Generating search prompt, query: {request.query}, timeframe: {request.timeframe}")
|
||||
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = SearchQuery(text=request.query, after_date=request.timeframe)
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
|
||||
template_context = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"results": search_results,
|
||||
"has_results": len(search_results) > 0,
|
||||
"result_count": len(search_results),
|
||||
}
|
||||
|
||||
try:
|
||||
# Render template
|
||||
rendered_prompt = await template_loader.render("prompts/search.hbs", template_context)
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"query": request.query,
|
||||
"timeframe": request.timeframe,
|
||||
"search_count": len(search_results),
|
||||
"context_count": len(search_results),
|
||||
"observation_count": 0, # Search results don't include observations
|
||||
"relation_count": 0, # Search results don't include relations
|
||||
"total_items": len(search_results),
|
||||
"search_limit": limit,
|
||||
"context_depth": 0, # No context depth for basic search
|
||||
"related_limit": 0, # No related items for basic search
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
prompt_metadata = PromptMetadata(**metadata)
|
||||
|
||||
return PromptResponse(
|
||||
prompt=rendered_prompt, context=template_context, metadata=prompt_metadata
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering search template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error rendering prompt template: {str(e)}",
|
||||
)
|
||||
@@ -1,252 +0,0 @@
|
||||
"""Routes for getting entity content."""
|
||||
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Union
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Body, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
ProjectConfigDep,
|
||||
LinkResolverDep,
|
||||
SearchServiceDep,
|
||||
EntityServiceDep,
|
||||
FileServiceDep,
|
||||
EntityRepositoryDep,
|
||||
)
|
||||
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
|
||||
from basic_memory.models.knowledge import Entity as EntityModel
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/resource", tags=["resources"])
|
||||
|
||||
|
||||
def _mtime_to_datetime(entity: EntityModel) -> datetime:
|
||||
"""Convert entity mtime (file modification time) to datetime.
|
||||
|
||||
Returns the file's actual modification time, falling back to updated_at
|
||||
if mtime is not available.
|
||||
"""
|
||||
if entity.mtime: # pragma: no cover
|
||||
return datetime.fromtimestamp(entity.mtime).astimezone() # pragma: no cover
|
||||
return entity.updated_at
|
||||
|
||||
|
||||
def get_entity_ids(item: SearchIndexRow) -> set[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}", response_model=None)
|
||||
async def get_resource_content(
|
||||
config: ProjectConfigDep,
|
||||
link_resolver: LinkResolverDep,
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
file_service: FileServiceDep,
|
||||
background_tasks: BackgroundTasks,
|
||||
identifier: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> Union[Response, FileResponse]:
|
||||
"""Get resource content by identifier: name or permalink."""
|
||||
logger.debug(f"Getting content for: {identifier}")
|
||||
|
||||
# Find single entity by permalink
|
||||
entity = await link_resolver.resolve_link(identifier)
|
||||
results = [entity] if entity else []
|
||||
|
||||
# pagination for multiple results
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 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)
|
||||
)
|
||||
search_results = await search_service.search(query, limit, offset)
|
||||
if not search_results:
|
||||
raise HTTPException(status_code=404, detail=f"Resource not found: {identifier}")
|
||||
|
||||
# get the deduplicated 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(list(entity_ids))
|
||||
|
||||
# return single response
|
||||
if len(results) == 1:
|
||||
entity = results[0]
|
||||
# Check file exists via file_service (for cloud compatibility)
|
||||
if not await file_service.exists(entity.file_path):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found: {entity.file_path}",
|
||||
)
|
||||
# Read content via file_service as bytes (works with both local and S3)
|
||||
content = await file_service.read_file_bytes(entity.file_path)
|
||||
content_type = file_service.content_type(entity.file_path)
|
||||
return Response(content=content, media_type=content_type)
|
||||
|
||||
# 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 = _mtime_to_datetime(result).isoformat()
|
||||
checksum = result.checksum[:8] if result.checksum else ""
|
||||
|
||||
# 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}")
|
||||
|
||||
|
||||
@router.put("/{file_path:path}")
|
||||
async def write_resource(
|
||||
config: ProjectConfigDep,
|
||||
file_service: FileServiceDep,
|
||||
entity_repository: EntityRepositoryDep,
|
||||
search_service: SearchServiceDep,
|
||||
file_path: str,
|
||||
content: Annotated[str, Body()],
|
||||
) -> JSONResponse:
|
||||
"""Write content to a file in the project.
|
||||
|
||||
This endpoint allows writing content directly to a file in the project.
|
||||
Also creates an entity record and indexes the file for search.
|
||||
|
||||
Args:
|
||||
file_path: Path to write to, relative to project root
|
||||
request: Contains the content to write
|
||||
|
||||
Returns:
|
||||
JSON response with file information
|
||||
"""
|
||||
try:
|
||||
# Get content from request body
|
||||
|
||||
# Defensive type checking: ensure content is a string
|
||||
# FastAPI should validate this, but if a dict somehow gets through
|
||||
# (e.g., via JSON body parsing), we need to catch it here
|
||||
if isinstance(content, dict):
|
||||
logger.error( # pragma: no cover
|
||||
f"Error writing resource {file_path}: "
|
||||
f"content is a dict, expected string. Keys: {list(content.keys())}"
|
||||
)
|
||||
raise HTTPException( # pragma: no cover
|
||||
status_code=400,
|
||||
detail="content must be a string, not a dict. "
|
||||
"Ensure request body is sent as raw string content, not JSON object.",
|
||||
)
|
||||
|
||||
# Ensure it's UTF-8 string content
|
||||
if isinstance(content, bytes): # pragma: no cover
|
||||
content_str = content.decode("utf-8")
|
||||
else:
|
||||
content_str = str(content)
|
||||
|
||||
# Cloud compatibility: do not assume a local filesystem path structure.
|
||||
# Delegate directory creation + writes to the configured FileService (local or S3).
|
||||
await file_service.ensure_directory(Path(file_path).parent)
|
||||
checksum = await file_service.write_file(file_path, content_str)
|
||||
|
||||
# Get file info
|
||||
file_metadata = await file_service.get_file_metadata(file_path)
|
||||
|
||||
# Determine file details
|
||||
file_name = Path(file_path).name
|
||||
content_type = file_service.content_type(file_path)
|
||||
|
||||
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Check if entity already exists
|
||||
existing_entity = await entity_repository.get_by_file_path(file_path)
|
||||
|
||||
if existing_entity:
|
||||
# Update existing entity
|
||||
entity = await entity_repository.update(
|
||||
existing_entity.id,
|
||||
{
|
||||
"title": file_name,
|
||||
"entity_type": entity_type,
|
||||
"content_type": content_type,
|
||||
"file_path": file_path,
|
||||
"checksum": checksum,
|
||||
"updated_at": file_metadata.modified_at,
|
||||
},
|
||||
)
|
||||
status_code = 200
|
||||
else:
|
||||
# Create a new entity model
|
||||
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
|
||||
entity = EntityModel(
|
||||
external_id=str(uuid.uuid4()),
|
||||
title=file_name,
|
||||
entity_type=entity_type,
|
||||
content_type=content_type,
|
||||
file_path=file_path,
|
||||
checksum=checksum,
|
||||
created_at=file_metadata.created_at,
|
||||
updated_at=file_metadata.modified_at,
|
||||
)
|
||||
entity = await entity_repository.add(entity)
|
||||
status_code = 201
|
||||
|
||||
# Index the file for search
|
||||
await search_service.index_entity(entity) # pyright: ignore
|
||||
|
||||
# Return success response
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"file_path": file_path,
|
||||
"checksum": checksum,
|
||||
"size": file_metadata.size,
|
||||
"created_at": file_metadata.created_at.timestamp(),
|
||||
"modified_at": file_metadata.modified_at.timestamp(),
|
||||
},
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Error writing resource {file_path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to write resource: {str(e)}")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Router for search operations."""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
|
||||
from basic_memory.api.routers.utils import to_search_results
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import SearchServiceDep, EntityServiceDep
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
|
||||
|
||||
@router.post("/", response_model=SearchResponse)
|
||||
async def search(
|
||||
query: SearchQuery,
|
||||
search_service: SearchServiceDep,
|
||||
entity_service: EntityServiceDep,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
):
|
||||
"""Search across all knowledge and documents."""
|
||||
limit = page_size
|
||||
offset = (page - 1) * page_size
|
||||
results = await search_service.search(query, limit=limit, offset=offset)
|
||||
search_results = await to_search_results(entity_service, results)
|
||||
return SearchResponse(
|
||||
results=search_results,
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reindex")
|
||||
async def reindex(background_tasks: BackgroundTasks, search_service: SearchServiceDep):
|
||||
"""Recreate and populate the search index."""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
@@ -10,7 +10,7 @@ Key improvements:
|
||||
- Simplified caching strategies
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response, Path
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response, Path, Query
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.deps import (
|
||||
@@ -19,9 +19,10 @@ from basic_memory.deps import (
|
||||
LinkResolverV2ExternalDep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
AppConfigDep,
|
||||
SyncServiceV2ExternalDep,
|
||||
EntityRepositoryV2ExternalDep,
|
||||
ProjectExternalIdPathDep,
|
||||
TaskSchedulerDep,
|
||||
FileServiceV2ExternalDep,
|
||||
)
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.base import Entity
|
||||
@@ -38,26 +39,6 @@ from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteRe
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
|
||||
|
||||
|
||||
async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
|
||||
"""Background task to resolve relations for a specific entity.
|
||||
|
||||
This runs asynchronously after the API response is sent, preventing
|
||||
long delays when creating entities with many relations.
|
||||
"""
|
||||
try: # pragma: no cover
|
||||
# Only resolve relations for the newly created entity
|
||||
await sync_service.resolve_relations(entity_id=entity_id) # pragma: no cover
|
||||
logger.debug( # pragma: no cover
|
||||
f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
|
||||
)
|
||||
except Exception as e: # pragma: no cover
|
||||
# Log but don't fail - this is a background task
|
||||
logger.warning( # pragma: no cover
|
||||
f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
|
||||
)
|
||||
|
||||
|
||||
## Resolution endpoint
|
||||
|
||||
|
||||
@@ -186,24 +167,43 @@ async def create_entity(
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
data: Entity data to create
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Created entity with generated external_id (UUID)
|
||||
Created entity with generated external_id (UUID) and file content
|
||||
"""
|
||||
logger.info(
|
||||
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
||||
)
|
||||
|
||||
entity = await entity_service.create_entity(data)
|
||||
if fast:
|
||||
entity = await entity_service.fast_write_entity(data)
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
entity = await entity_service.create_entity(data)
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
# Always read and return file content
|
||||
content = await file_service.read_file_content(entity.file_path)
|
||||
result = result.model_copy(update={"content": content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: endpoint='create_entity' external_id={entity.external_id}, title={result.title}, permalink={result.permalink}, status_code=201"
|
||||
@@ -222,9 +222,13 @@ async def update_entity_by_id(
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Update an entity by external ID.
|
||||
|
||||
@@ -233,30 +237,55 @@ async def update_entity_by_id(
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
data: Updated entity data
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Updated entity
|
||||
Updated entity with file content
|
||||
"""
|
||||
logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
|
||||
|
||||
# Check if entity exists
|
||||
# Check if entity exists (external_id is the source of truth for v2)
|
||||
existing = await entity_repository.get_by_external_id(entity_id)
|
||||
created = existing is None
|
||||
|
||||
# Perform update or create
|
||||
entity, _ = await entity_service.create_or_update_entity(data)
|
||||
response.status_code = 201 if created else 200
|
||||
|
||||
# reindex
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
# Schedule relation resolution for new entities
|
||||
if created:
|
||||
background_tasks.add_task( # pragma: no cover
|
||||
resolve_relations_background, sync_service, entity.id, entity.permalink or ""
|
||||
if fast:
|
||||
entity = await entity_service.fast_write_entity(data, external_id=entity_id)
|
||||
response.status_code = 200 if existing else 201
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=entity.id,
|
||||
project_id=project_id,
|
||||
resolve_relations=created,
|
||||
)
|
||||
else:
|
||||
if existing:
|
||||
# Update the existing entity in-place to avoid path-based duplication
|
||||
entity = await entity_service.update_entity(existing, data)
|
||||
response.status_code = 200
|
||||
else:
|
||||
# Create new entity, then bind external_id to the requested UUID
|
||||
entity = await entity_service.create_entity(data)
|
||||
if entity.external_id != entity_id:
|
||||
entity = await entity_repository.update(
|
||||
entity.id,
|
||||
{"external_id": entity_id},
|
||||
)
|
||||
if not entity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Entity with external_id '{entity_id}' not found",
|
||||
)
|
||||
response.status_code = 201
|
||||
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
# Always read and return file content
|
||||
content = await file_service.read_file_content(entity.file_path)
|
||||
result = result.model_copy(update={"content": content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: external_id={entity_id}, created={created}, status_code={response.status_code}"
|
||||
@@ -272,16 +301,22 @@ async def edit_entity_by_id(
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
file_service: FileServiceV2ExternalDep,
|
||||
entity_id: str = Path(..., description="Entity external ID (UUID)"),
|
||||
fast: bool = Query(
|
||||
True, description="If true, write quickly and defer indexing to background tasks."
|
||||
),
|
||||
) -> EntityResponseV2:
|
||||
"""Edit an existing entity by external ID using operations like append, prepend, etc.
|
||||
|
||||
Args:
|
||||
entity_id: External ID (UUID string)
|
||||
data: Edit operation details
|
||||
fast: If True, defer indexing to background tasks
|
||||
|
||||
Returns:
|
||||
Updated entity
|
||||
Updated entity with file content
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if entity not found, 400 if edit fails
|
||||
@@ -298,21 +333,41 @@ async def edit_entity_by_id(
|
||||
)
|
||||
|
||||
try:
|
||||
# Edit using the entity's permalink or path
|
||||
identifier = entity.permalink or entity.file_path
|
||||
updated_entity = await entity_service.edit_entity(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
if fast:
|
||||
updated_entity = await entity_service.fast_edit_entity(
|
||||
entity=entity,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
task_scheduler.schedule(
|
||||
"reindex_entity",
|
||||
entity_id=updated_entity.id,
|
||||
project_id=project_id,
|
||||
)
|
||||
else:
|
||||
# Edit using the entity's permalink or path
|
||||
identifier = entity.permalink or entity.file_path
|
||||
updated_entity = await entity_service.edit_entity(
|
||||
identifier=identifier,
|
||||
operation=data.operation,
|
||||
content=data.content,
|
||||
section=data.section,
|
||||
find_text=data.find_text,
|
||||
expected_replacements=data.expected_replacements,
|
||||
)
|
||||
|
||||
# Reindex
|
||||
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
|
||||
await search_service.index_entity(updated_entity, background_tasks=background_tasks)
|
||||
|
||||
result = EntityResponseV2.model_validate(updated_entity)
|
||||
if fast:
|
||||
result = result.model_copy(update={"observations": [], "relations": []})
|
||||
|
||||
# Always read and return file content
|
||||
content = await file_service.read_file_content(updated_entity.file_path)
|
||||
result = result.model_copy(update={"content": content})
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: external_id={entity_id}, operation='{data.operation}', status_code=200"
|
||||
|
||||
@@ -16,7 +16,7 @@ from basic_memory.schemas.memory import (
|
||||
normalize_memory_url,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
from basic_memory.api.routers.utils import to_graph_context
|
||||
from basic_memory.api.v2.utils import to_graph_context
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/memory
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
@@ -19,9 +19,17 @@ from loguru import logger
|
||||
from basic_memory.deps import (
|
||||
ProjectServiceDep,
|
||||
ProjectRepositoryDep,
|
||||
ProjectConfigV2ExternalDep,
|
||||
SyncServiceV2ExternalDep,
|
||||
TaskSchedulerDep,
|
||||
ProjectExternalIdPathDep,
|
||||
)
|
||||
from basic_memory.schemas import SyncReportResponse
|
||||
from basic_memory.schemas.project_info import (
|
||||
ProjectItem,
|
||||
ProjectList,
|
||||
ProjectInfoRequest,
|
||||
ProjectInfoResponse,
|
||||
ProjectStatusResponse,
|
||||
)
|
||||
from basic_memory.schemas.v2 import ProjectResolveRequest, ProjectResolveResponse
|
||||
@@ -30,6 +38,177 @@ from basic_memory.utils import normalize_project_path, generate_permalink
|
||||
router = APIRouter(prefix="/projects", tags=["project_management-v2"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ProjectList)
|
||||
async def list_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectList:
|
||||
"""List all configured projects.
|
||||
|
||||
Returns:
|
||||
A list of all projects with metadata
|
||||
"""
|
||||
projects = await project_service.list_projects()
|
||||
default_project = project_service.default_project
|
||||
|
||||
project_items = [
|
||||
ProjectItem(
|
||||
id=project.id,
|
||||
external_id=project.external_id,
|
||||
name=project.name,
|
||||
path=normalize_project_path(project.path),
|
||||
is_default=project.is_default or False,
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
|
||||
return ProjectList(
|
||||
projects=project_items,
|
||||
default_project=default_project,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=ProjectStatusResponse, status_code=201)
|
||||
async def add_project(
|
||||
project_data: ProjectInfoRequest,
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Add a new project to configuration and database.
|
||||
|
||||
Args:
|
||||
project_data: The project name and path, with option to set as default
|
||||
|
||||
Returns:
|
||||
Response confirming the project was added
|
||||
"""
|
||||
# Check if project already exists before attempting to add
|
||||
existing_project = await project_service.get_project(project_data.name)
|
||||
if existing_project:
|
||||
# Project exists - check if paths match for true idempotency
|
||||
# Normalize paths for comparison (resolve symlinks, etc.)
|
||||
from pathlib import Path
|
||||
|
||||
requested_path = Path(project_data.path).resolve()
|
||||
existing_path = Path(existing_project.path).resolve()
|
||||
|
||||
if requested_path == existing_path:
|
||||
# Same name, same path - return 200 OK (idempotent)
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{project_data.name}' already exists",
|
||||
status="success",
|
||||
default=existing_project.is_default or False,
|
||||
new_project=ProjectItem(
|
||||
id=existing_project.id,
|
||||
external_id=existing_project.external_id,
|
||||
name=existing_project.name,
|
||||
path=existing_project.path,
|
||||
is_default=existing_project.is_default or False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Same name, different path - this is an error
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Project '{project_data.name}' already exists with different path. "
|
||||
f"Existing: {existing_project.path}, Requested: {project_data.path}"
|
||||
),
|
||||
)
|
||||
|
||||
try: # pragma: no cover
|
||||
# The service layer handles cloud mode validation and path sanitization
|
||||
await project_service.add_project(
|
||||
project_data.name, project_data.path, set_default=project_data.set_default
|
||||
)
|
||||
|
||||
# Fetch the newly created project to get its ID
|
||||
new_project = await project_service.get_project(project_data.name)
|
||||
if not new_project:
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message=f"Project '{new_project.name}' added successfully",
|
||||
status="success",
|
||||
default=project_data.set_default,
|
||||
new_project=ProjectItem(
|
||||
id=new_project.id,
|
||||
external_id=new_project.external_id,
|
||||
name=new_project.name,
|
||||
path=new_project.path,
|
||||
is_default=new_project.is_default or False,
|
||||
),
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/config/sync", response_model=ProjectStatusResponse)
|
||||
async def synchronize_projects(
|
||||
project_service: ProjectServiceDep,
|
||||
) -> ProjectStatusResponse:
|
||||
"""Synchronize projects between configuration file and database."""
|
||||
try: # pragma: no cover
|
||||
await project_service.synchronize_projects()
|
||||
|
||||
return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
|
||||
message="Projects synchronized successfully between configuration and database",
|
||||
status="success",
|
||||
default=False,
|
||||
)
|
||||
except ValueError as e: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{project_id}/sync")
|
||||
async def sync_project(
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
project_internal_id: ProjectExternalIdPathDep,
|
||||
force_full: bool = Query(
|
||||
False, description="Force full scan, bypassing watermark optimization"
|
||||
),
|
||||
run_in_background: bool = Query(True, description="Run in background"),
|
||||
):
|
||||
"""Force project filesystem sync to database."""
|
||||
if run_in_background:
|
||||
task_scheduler.schedule(
|
||||
"sync_project",
|
||||
project_id=project_internal_id,
|
||||
force_full=force_full,
|
||||
)
|
||||
logger.info(
|
||||
f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "sync_started",
|
||||
"message": f"Filesystem sync initiated for project '{project_config.name}'",
|
||||
}
|
||||
|
||||
report = await sync_service.sync(
|
||||
project_config.home, project_config.name, force_full=force_full
|
||||
)
|
||||
logger.info(
|
||||
f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
|
||||
)
|
||||
return SyncReportResponse.from_sync_report(report)
|
||||
|
||||
|
||||
@router.post("/{project_id}/status", response_model=SyncReportResponse)
|
||||
async def get_project_status(
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
force_full: bool = Query(
|
||||
False, description="Force full scan, bypassing watermark optimization"
|
||||
),
|
||||
) -> SyncReportResponse:
|
||||
"""Get sync status of files vs database for a project."""
|
||||
logger.info(f"API v2 request: get_project_status for project_id={project_id}")
|
||||
report = await sync_service.scan(project_config.home, force_full=force_full)
|
||||
return SyncReportResponse.from_sync_report(report)
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=ProjectResolveResponse)
|
||||
async def resolve_project_identifier(
|
||||
data: ProjectResolveRequest,
|
||||
@@ -147,6 +326,22 @@ async def get_project_by_id(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/info", response_model=ProjectInfoResponse)
|
||||
async def get_project_info_by_id(
|
||||
project_service: ProjectServiceDep,
|
||||
project_repository: ProjectRepositoryDep,
|
||||
project_id: str = Path(..., description="Project external ID (UUID)"),
|
||||
) -> ProjectInfoResponse:
|
||||
"""Get detailed project information by external ID."""
|
||||
logger.info(f"API v2 request: get_project_info_by_id for project_id={project_id}")
|
||||
project = await project_repository.get_by_external_id(project_id)
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Project with external_id '{project_id}' not found"
|
||||
)
|
||||
return await project_service.get_project_info(project.name)
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectStatusResponse)
|
||||
async def update_project_by_id(
|
||||
project_service: ProjectServiceDep,
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, status, Path
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.api.routers.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.v2.utils import to_graph_context, to_search_results
|
||||
from basic_memory.api.template_loader import template_loader
|
||||
from basic_memory.schemas.base import parse_timeframe
|
||||
from basic_memory.deps import (
|
||||
|
||||
@@ -4,11 +4,16 @@ This router uses external_id UUIDs for stable, API-friendly routing.
|
||||
V1 uses string-based project names which are less efficient and less stable.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Path
|
||||
from fastapi import APIRouter, Path
|
||||
|
||||
from basic_memory.api.routers.utils import to_search_results
|
||||
from basic_memory.api.v2.utils import to_search_results
|
||||
from basic_memory.schemas.search import SearchQuery, SearchResponse
|
||||
from basic_memory.deps import SearchServiceV2ExternalDep, EntityServiceV2ExternalDep
|
||||
from basic_memory.deps import (
|
||||
SearchServiceV2ExternalDep,
|
||||
EntityServiceV2ExternalDep,
|
||||
TaskSchedulerDep,
|
||||
ProjectExternalIdPathDep,
|
||||
)
|
||||
|
||||
# Note: No prefix here - it's added during registration as /v2/{project_id}/search
|
||||
router = APIRouter(tags=["search"])
|
||||
@@ -51,9 +56,8 @@ async def search(
|
||||
|
||||
@router.post("/search/reindex")
|
||||
async def reindex(
|
||||
background_tasks: BackgroundTasks,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
task_scheduler: TaskSchedulerDep,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
):
|
||||
"""Recreate and populate the search index for a project.
|
||||
|
||||
@@ -63,11 +67,10 @@ async def reindex(
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
background_tasks: FastAPI background tasks handler
|
||||
search_service: Search service scoped to project
|
||||
task_scheduler: Task scheduler for background work
|
||||
|
||||
Returns:
|
||||
Status message indicating reindex has been initiated
|
||||
"""
|
||||
await search_service.reindex_all(background_tasks=background_tasks)
|
||||
task_scheduler.schedule("reindex_project", project_id=project_id)
|
||||
return {"status": "ok", "message": "Reindex initiated"}
|
||||
|
||||
@@ -24,29 +24,42 @@ async def to_graph_context(
|
||||
page: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
):
|
||||
# First pass: collect all entity IDs needed for relations
|
||||
# First pass: collect all entity IDs needed for external_id lookup
|
||||
# This includes: entity primary results, observation parent entities, relation from/to entities
|
||||
entity_ids_needed: set[int] = set()
|
||||
for context_item in context_result.results:
|
||||
for item in (
|
||||
[context_item.primary_result] + context_item.observations + context_item.related_results
|
||||
):
|
||||
if item.type == SearchItemType.RELATION:
|
||||
if item.type == SearchItemType.ENTITY:
|
||||
# Entity's own ID for its external_id
|
||||
entity_ids_needed.add(item.id)
|
||||
elif item.type == SearchItemType.OBSERVATION:
|
||||
# Parent entity ID for entity_external_id
|
||||
if item.entity_id: # pyright: ignore
|
||||
entity_ids_needed.add(item.entity_id) # pyright: ignore
|
||||
elif item.type == SearchItemType.RELATION:
|
||||
# Source and target entity IDs for external_ids
|
||||
if item.from_id: # pyright: ignore
|
||||
entity_ids_needed.add(item.from_id) # pyright: ignore
|
||||
if item.to_id:
|
||||
entity_ids_needed.add(item.to_id)
|
||||
|
||||
# Batch fetch all entities at once
|
||||
entity_lookup: dict[int, str] = {}
|
||||
# Batch fetch all entities at once - get both title and external_id
|
||||
entity_title_lookup: dict[int, str] = {}
|
||||
entity_external_id_lookup: dict[int, str] = {}
|
||||
if entity_ids_needed:
|
||||
entities = await entity_repository.find_by_ids(list(entity_ids_needed))
|
||||
entity_lookup = {e.id: e.title for e in entities}
|
||||
for e in entities:
|
||||
entity_title_lookup[e.id] = e.title
|
||||
entity_external_id_lookup[e.id] = e.external_id
|
||||
|
||||
# Helper function to convert items to summaries
|
||||
def to_summary(item: SearchIndexRow | ContextResultRow):
|
||||
match item.type:
|
||||
case SearchItemType.ENTITY:
|
||||
return EntitySummary(
|
||||
external_id=entity_external_id_lookup.get(item.id, ""),
|
||||
entity_id=item.id,
|
||||
title=item.title, # pyright: ignore
|
||||
permalink=item.permalink,
|
||||
@@ -55,10 +68,14 @@ async def to_graph_context(
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.OBSERVATION:
|
||||
entity_ext_id = None
|
||||
if item.entity_id: # pyright: ignore
|
||||
entity_ext_id = entity_external_id_lookup.get(item.entity_id) # pyright: ignore
|
||||
return ObservationSummary(
|
||||
observation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
title=item.title, # pyright: ignore
|
||||
entity_external_id=entity_ext_id,
|
||||
title=entity_title_lookup.get(item.entity_id), # pyright: ignore
|
||||
file_path=item.file_path,
|
||||
category=item.category, # pyright: ignore
|
||||
content=item.content, # pyright: ignore
|
||||
@@ -66,8 +83,10 @@ async def to_graph_context(
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case SearchItemType.RELATION:
|
||||
from_title = entity_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
||||
to_title = entity_lookup.get(item.to_id) if item.to_id else None
|
||||
from_title = entity_title_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
||||
to_title = entity_title_lookup.get(item.to_id) if item.to_id else None
|
||||
from_ext_id = entity_external_id_lookup.get(item.from_id) if item.from_id else None # pyright: ignore
|
||||
to_ext_id = entity_external_id_lookup.get(item.to_id) if item.to_id else None
|
||||
return RelationSummary(
|
||||
relation_id=item.id,
|
||||
entity_id=item.entity_id, # pyright: ignore
|
||||
@@ -77,8 +96,10 @@ async def to_graph_context(
|
||||
relation_type=item.relation_type, # pyright: ignore
|
||||
from_entity=from_title,
|
||||
from_entity_id=item.from_id, # pyright: ignore
|
||||
from_entity_external_id=from_ext_id,
|
||||
to_entity=to_title,
|
||||
to_entity_id=item.to_id,
|
||||
to_entity_external_id=to_ext_id,
|
||||
created_at=item.created_at,
|
||||
)
|
||||
case _: # pragma: no cover
|
||||
@@ -30,7 +30,7 @@ async def fetch_cloud_projects(
|
||||
config = config_manager.config
|
||||
host_url = config.cloud_host.rstrip("/")
|
||||
|
||||
response = await api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
|
||||
response = await api_request(method="GET", url=f"{host_url}/proxy/v2/projects/")
|
||||
|
||||
return CloudProjectList.model_validate(response.json())
|
||||
except Exception as e:
|
||||
@@ -66,7 +66,7 @@ async def create_cloud_project(
|
||||
|
||||
response = await api_request(
|
||||
method="POST",
|
||||
url=f"{host_url}/proxy/projects/projects",
|
||||
url=f"{host_url}/proxy/v2/projects/",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json_data=project_data.model_dump(),
|
||||
)
|
||||
|
||||
@@ -58,7 +58,7 @@ async def run_sync(
|
||||
try:
|
||||
async with get_client() as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
url = f"{project_item.project_url}/project/sync"
|
||||
url = f"/v2/projects/{project_item.external_id}/sync"
|
||||
params = []
|
||||
if force_full:
|
||||
params.append("force_full=true")
|
||||
@@ -92,7 +92,7 @@ async def get_project_info(project: str):
|
||||
try:
|
||||
async with get_client() as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
response = await call_get(client, f"{project_item.project_url}/project/info")
|
||||
response = await call_get(client, f"/v2/projects/{project_item.external_id}/info")
|
||||
return ProjectInfoResponse.model_validate(response.json())
|
||||
except (ToolError, ValueError) as e:
|
||||
console.print(f"[red]Sync failed: {e}[/red]")
|
||||
|
||||
@@ -17,6 +17,7 @@ from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
|
||||
# Import rclone commands for project sync
|
||||
@@ -65,7 +66,7 @@ def list_projects(
|
||||
|
||||
async def _list_projects():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/projects/projects")
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
return ProjectList.model_validate(response.json())
|
||||
|
||||
try:
|
||||
@@ -167,7 +168,7 @@ def add_project(
|
||||
"local_sync_path": local_sync_path,
|
||||
"set_default": set_default,
|
||||
}
|
||||
response = await call_post(client, "/projects/projects", json=data)
|
||||
response = await call_post(client, "/v2/projects/", json=data)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
else:
|
||||
# Local mode: path is required
|
||||
@@ -181,7 +182,7 @@ def add_project(
|
||||
async def _add_project():
|
||||
async with get_client() as client:
|
||||
data = {"name": name, "path": resolved_path, "set_default": set_default}
|
||||
response = await call_post(client, "/projects/projects", json=data)
|
||||
response = await call_post(client, "/v2/projects/", json=data)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
@@ -234,7 +235,7 @@ def setup_project_sync(
|
||||
async def _verify_project_exists():
|
||||
"""Verify the project exists on cloud by listing all projects."""
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/projects/projects")
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
project_list = response.json()
|
||||
project_names = [p["name"] for p in project_list["projects"]]
|
||||
if name not in project_names:
|
||||
@@ -433,7 +434,7 @@ def synchronize_projects(
|
||||
|
||||
async def _sync_config():
|
||||
async with get_client() as client:
|
||||
response = await call_post(client, "/projects/config/sync")
|
||||
response = await call_post(client, "/v2/projects/config/sync")
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
@@ -475,10 +476,15 @@ def move_project(
|
||||
async def _move_project():
|
||||
async with get_client() as client:
|
||||
data = {"path": resolved_path}
|
||||
project_permalink = generate_permalink(name)
|
||||
|
||||
# TODO fix route to use ProjectPathDep
|
||||
response = await call_patch(client, f"/{name}/project/{project_permalink}", json=data)
|
||||
resolve_response = await call_post(
|
||||
client,
|
||||
"/v2/projects/resolve",
|
||||
json={"identifier": name},
|
||||
)
|
||||
project_info = ProjectResolveResponse.model_validate(resolve_response.json())
|
||||
response = await call_patch(
|
||||
client, f"/v2/projects/{project_info.external_id}", json=data
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
@@ -530,7 +536,7 @@ def sync_project_command(
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/projects/projects")
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
@@ -571,9 +577,10 @@ def sync_project_command(
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client() as client:
|
||||
permalink = generate_permalink(name)
|
||||
response = await call_post(
|
||||
client, f"/{permalink}/project/sync?force_full=true", json={}
|
||||
client,
|
||||
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
|
||||
json={},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
@@ -621,7 +628,7 @@ def bisync_project_command(
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/projects/projects")
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
@@ -669,9 +676,10 @@ def bisync_project_command(
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client() as client:
|
||||
permalink = generate_permalink(name)
|
||||
response = await call_post(
|
||||
client, f"/{permalink}/project/sync?force_full=true", json={}
|
||||
client,
|
||||
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
|
||||
json={},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
@@ -715,7 +723,7 @@ def check_project_command(
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/projects/projects")
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
@@ -816,7 +824,7 @@ def ls_project_command(
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/projects/projects")
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
|
||||
@@ -146,7 +146,7 @@ async def run_status(project: Optional[str] = None, verbose: bool = False): # p
|
||||
try:
|
||||
async with get_client() as client:
|
||||
project_item = await get_active_project(client, project, None)
|
||||
response = await call_post(client, f"{project_item.project_url}/project/status")
|
||||
response = await call_post(client, f"/v2/projects/{project_item.external_id}/status")
|
||||
sync_report = SyncReportResponse.model_validate(response.json())
|
||||
|
||||
display_changes(project_item.name, "Status", sync_report, verbose)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""CLI tool commands for Basic Memory."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Annotated, List, Optional
|
||||
|
||||
@@ -288,7 +289,10 @@ def recent_activity(
|
||||
|
||||
@tool_app.command("search-notes")
|
||||
def search_notes(
|
||||
query: str,
|
||||
query: Annotated[
|
||||
Optional[str],
|
||||
typer.Argument(help="Search query string (optional when using metadata filters)"),
|
||||
] = "",
|
||||
permalink: Annotated[bool, typer.Option("--permalink", help="Search permalink values")] = False,
|
||||
title: Annotated[bool, typer.Option("--title", help="Search title values")] = False,
|
||||
project: Annotated[
|
||||
@@ -301,6 +305,26 @@ def search_notes(
|
||||
Optional[str],
|
||||
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
|
||||
] = None,
|
||||
tags: Annotated[
|
||||
Optional[List[str]],
|
||||
typer.Option("--tag", help="Filter by frontmatter tag (repeatable)"),
|
||||
] = None,
|
||||
status: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--status", help="Filter by frontmatter status"),
|
||||
] = None,
|
||||
note_types: Annotated[
|
||||
Optional[List[str]],
|
||||
typer.Option("--type", help="Filter by frontmatter type (repeatable)"),
|
||||
] = None,
|
||||
meta: Annotated[
|
||||
Optional[List[str]],
|
||||
typer.Option("--meta", help="Filter by frontmatter key=value (repeatable)"),
|
||||
] = None,
|
||||
filter_json: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--filter", help="JSON metadata filter (advanced)"),
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
local: bool = typer.Option(
|
||||
@@ -335,26 +359,59 @@ def search_notes(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Build metadata filters from --filter and --meta
|
||||
metadata_filters = {}
|
||||
if filter_json:
|
||||
try:
|
||||
metadata_filters = json.loads(filter_json)
|
||||
if not isinstance(metadata_filters, dict):
|
||||
raise ValueError("Metadata filter JSON must be an object")
|
||||
except json.JSONDecodeError as e:
|
||||
typer.echo(f"Invalid JSON for --filter: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if meta:
|
||||
for item in meta:
|
||||
if "=" not in item:
|
||||
typer.echo(
|
||||
f"Invalid --meta entry '{item}'. Use key=value format.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
key, value = item.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key:
|
||||
typer.echo(f"Invalid --meta entry '{item}'.", err=True)
|
||||
raise typer.Exit(1)
|
||||
metadata_filters[key] = value
|
||||
|
||||
if not metadata_filters:
|
||||
metadata_filters = None
|
||||
|
||||
# set search type
|
||||
search_type = ("permalink" if permalink else None,)
|
||||
search_type = ("permalink_match" if permalink and "*" in query else None,)
|
||||
search_type = ("title" if title else None,)
|
||||
search_type = "text" if search_type is None else search_type
|
||||
search_type = "text"
|
||||
if permalink:
|
||||
search_type = "permalink"
|
||||
if query and "*" in query:
|
||||
search_type = "permalink"
|
||||
if title:
|
||||
search_type = "title"
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
results = run_with_cleanup(
|
||||
mcp_search.fn(
|
||||
query,
|
||||
query or "",
|
||||
project_name,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
after_date=after_date,
|
||||
page_size=page_size,
|
||||
types=note_types,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
# Use json module for more controlled serialization
|
||||
import json
|
||||
|
||||
results_dict = results.model_dump(exclude_none=True)
|
||||
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
|
||||
except ValueError as e:
|
||||
|
||||
@@ -91,6 +91,8 @@ from basic_memory.deps.services import (
|
||||
FileServiceV2Dep,
|
||||
get_file_service_v2_external,
|
||||
FileServiceV2ExternalDep,
|
||||
get_task_scheduler,
|
||||
TaskSchedulerDep,
|
||||
get_search_service,
|
||||
SearchServiceDep,
|
||||
get_search_service_v2,
|
||||
@@ -227,6 +229,8 @@ __all__ = [
|
||||
"FileServiceV2Dep",
|
||||
"get_file_service_v2_external",
|
||||
"FileServiceV2ExternalDep",
|
||||
"get_task_scheduler",
|
||||
"TaskSchedulerDep",
|
||||
"get_search_service",
|
||||
"SearchServiceDep",
|
||||
"get_search_service_v2",
|
||||
|
||||
@@ -7,7 +7,8 @@ This module provides service-layer dependencies:
|
||||
- SyncService, ProjectService, DirectoryService
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
import asyncio
|
||||
from typing import Annotated, Any, Callable, Coroutine, Mapping, Protocol
|
||||
|
||||
from fastapi import Depends
|
||||
from loguru import logger
|
||||
@@ -43,7 +44,6 @@ from basic_memory.services.link_resolver import LinkResolver
|
||||
from basic_memory.services.search_service import SearchService
|
||||
from basic_memory.sync import SyncService
|
||||
|
||||
|
||||
# --- Entity Parser ---
|
||||
|
||||
|
||||
@@ -430,6 +430,87 @@ async def get_sync_service_v2_external(
|
||||
SyncServiceV2ExternalDep = Annotated[SyncService, Depends(get_sync_service_v2_external)]
|
||||
|
||||
|
||||
# --- Background Task Scheduler ---
|
||||
|
||||
|
||||
class TaskScheduler(Protocol):
|
||||
def schedule(self, task_name: str, **payload: Any) -> None:
|
||||
"""Schedule a background task by name."""
|
||||
|
||||
|
||||
def _log_task_failure(completed: asyncio.Task) -> None:
|
||||
try:
|
||||
completed.result()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception("Background task failed", error=str(exc))
|
||||
|
||||
|
||||
class LocalTaskScheduler:
|
||||
"""Default scheduler that runs tasks in-process via asyncio.create_task."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handlers: Mapping[str, Callable[..., Coroutine[Any, Any, None]]],
|
||||
) -> None:
|
||||
self._handlers = handlers
|
||||
|
||||
def schedule(self, task_name: str, **payload: Any) -> None:
|
||||
handler = self._handlers.get(task_name)
|
||||
# Trigger: task name is not registered
|
||||
# Why: avoid silently dropping background work
|
||||
# Outcome: fail fast to surface misconfiguration
|
||||
if not handler:
|
||||
raise ValueError(f"Unknown task name: {task_name}")
|
||||
task = asyncio.create_task(handler(**payload))
|
||||
task.add_done_callback(_log_task_failure)
|
||||
|
||||
|
||||
async def get_task_scheduler(
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
sync_service: SyncServiceV2ExternalDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
) -> TaskScheduler:
|
||||
"""Create a scheduler that maps task specs to coroutines."""
|
||||
|
||||
async def _reindex_entity(
|
||||
entity_id: int,
|
||||
resolve_relations: bool = False,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
await entity_service.reindex_entity(entity_id)
|
||||
# Trigger: caller requests relation resolution
|
||||
# Why: resolve forward references created before the entity existed
|
||||
# Outcome: updates unresolved relations pointing to this entity
|
||||
if resolve_relations:
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
|
||||
async def _resolve_relations(entity_id: int, **_: Any) -> None:
|
||||
await sync_service.resolve_relations(entity_id=entity_id)
|
||||
|
||||
async def _sync_project(force_full: bool = False, **_: Any) -> None:
|
||||
await sync_service.sync(
|
||||
project_config.home,
|
||||
project_config.name,
|
||||
force_full=force_full,
|
||||
)
|
||||
|
||||
async def _reindex_project(**_: Any) -> None:
|
||||
await search_service.reindex_all()
|
||||
|
||||
return LocalTaskScheduler(
|
||||
{
|
||||
"reindex_entity": _reindex_entity,
|
||||
"resolve_relations": _resolve_relations,
|
||||
"sync_project": _sync_project,
|
||||
"reindex_project": _reindex_project,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
TaskSchedulerDep = Annotated[TaskScheduler, Depends(get_task_scheduler)]
|
||||
|
||||
|
||||
# --- Project Service ---
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from frontmatter import Post
|
||||
|
||||
from basic_memory.file_utils import has_frontmatter, remove_frontmatter, parse_frontmatter
|
||||
from basic_memory.markdown import EntityMarkdown
|
||||
from basic_memory.markdown.entity_parser import normalize_frontmatter_metadata
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.models import Observation as ObservationModel
|
||||
|
||||
@@ -58,9 +59,9 @@ def entity_model_from_markdown(
|
||||
model.created_at = markdown.created
|
||||
model.updated_at = markdown.modified
|
||||
|
||||
# Handle metadata - ensure all values are strings and filter None
|
||||
metadata = markdown.frontmatter.metadata or {}
|
||||
model.entity_metadata = {k: str(v) for k, v in metadata.items() if v is not None}
|
||||
# Handle metadata - normalize values and filter None (preserve structured data)
|
||||
metadata = normalize_frontmatter_metadata(markdown.frontmatter.metadata or {})
|
||||
model.entity_metadata = {k: v for k, v in metadata.items() if v is not None}
|
||||
|
||||
# Get project_id from entity if not provided
|
||||
obs_project_id = project_id or (model.project_id if hasattr(model, "project_id") else None)
|
||||
|
||||
@@ -43,7 +43,9 @@ class KnowledgeClient:
|
||||
|
||||
# --- Entity CRUD Operations ---
|
||||
|
||||
async def create_entity(self, entity_data: dict[str, Any]) -> EntityResponse:
|
||||
async def create_entity(
|
||||
self, entity_data: dict[str, Any], *, fast: bool | None = None
|
||||
) -> EntityResponse:
|
||||
"""Create a new entity.
|
||||
|
||||
Args:
|
||||
@@ -55,14 +57,22 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params = {"fast": fast} if fast is not None else None
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities",
|
||||
json=entity_data,
|
||||
params=params,
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
async def update_entity(self, entity_id: str, entity_data: dict[str, Any]) -> EntityResponse:
|
||||
async def update_entity(
|
||||
self,
|
||||
entity_id: str,
|
||||
entity_data: dict[str, Any],
|
||||
*,
|
||||
fast: bool | None = None,
|
||||
) -> EntityResponse:
|
||||
"""Update an existing entity (full replacement).
|
||||
|
||||
Args:
|
||||
@@ -75,10 +85,12 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params = {"fast": fast} if fast is not None else None
|
||||
response = await call_put(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
json=entity_data,
|
||||
params=params,
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
@@ -100,7 +112,13 @@ class KnowledgeClient:
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
async def patch_entity(self, entity_id: str, patch_data: dict[str, Any]) -> EntityResponse:
|
||||
async def patch_entity(
|
||||
self,
|
||||
entity_id: str,
|
||||
patch_data: dict[str, Any],
|
||||
*,
|
||||
fast: bool | None = None,
|
||||
) -> EntityResponse:
|
||||
"""Partially update an entity.
|
||||
|
||||
Args:
|
||||
@@ -113,10 +131,12 @@ class KnowledgeClient:
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params = {"fast": fast} if fast is not None else None
|
||||
response = await call_patch(
|
||||
self.http_client,
|
||||
f"{self._base_path}/entities/{entity_id}",
|
||||
json=patch_data,
|
||||
params=params,
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class ProjectClient:
|
||||
"""
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
"/projects/projects",
|
||||
"/v2/projects/",
|
||||
)
|
||||
return ProjectList.model_validate(response.json())
|
||||
|
||||
@@ -65,7 +65,7 @@ class ProjectClient:
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
"/projects/projects",
|
||||
"/v2/projects/",
|
||||
json=project_data,
|
||||
)
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
@@ -19,7 +19,7 @@ from fastmcp import Context
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.project_resolver import ProjectResolver
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.utils import generate_permalink
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
|
||||
|
||||
async def resolve_project_parameter(
|
||||
@@ -78,7 +78,7 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
|
||||
# Deferred import to avoid circular dependency with tools
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
|
||||
response = await call_get(client, "/projects/projects", headers=headers)
|
||||
response = await call_get(client, "/v2/projects/", headers=headers)
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
return [project.name for project in project_list.projects]
|
||||
|
||||
@@ -104,7 +104,7 @@ async def get_active_project(
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
"""
|
||||
# Deferred import to avoid circular dependency with tools
|
||||
from basic_memory.mcp.tools.utils import call_get
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
|
||||
resolved_project = await resolve_project_parameter(project)
|
||||
if not resolved_project:
|
||||
@@ -126,9 +126,20 @@ async def get_active_project(
|
||||
|
||||
# Validate project exists by calling API
|
||||
logger.debug(f"Validating project: {project}")
|
||||
permalink = generate_permalink(project)
|
||||
response = await call_get(client, f"/{permalink}/project/item", headers=headers)
|
||||
active_project = ProjectItem.model_validate(response.json())
|
||||
response = await call_post(
|
||||
client,
|
||||
"/v2/projects/resolve",
|
||||
json={"identifier": project},
|
||||
headers=headers,
|
||||
)
|
||||
resolved = ProjectResolveResponse.model_validate(response.json())
|
||||
active_project = ProjectItem(
|
||||
id=resolved.project_id,
|
||||
external_id=resolved.external_id,
|
||||
name=resolved.name,
|
||||
path=resolved.path,
|
||||
is_default=resolved.is_default,
|
||||
)
|
||||
|
||||
# Cache in context if available
|
||||
if context:
|
||||
|
||||
@@ -9,8 +9,9 @@ from typing import Annotated, Optional
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.prompt import ContinueConversationRequest
|
||||
@@ -42,17 +43,18 @@ async def continue_conversation(
|
||||
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
|
||||
|
||||
async with get_client() as client:
|
||||
config = ConfigManager().config
|
||||
active_project = await get_active_project(client, project=config.default_project)
|
||||
|
||||
# Create request model
|
||||
request = ContinueConversationRequest( # pyright: ignore [reportCallIssue]
|
||||
topic=topic, timeframe=timeframe
|
||||
)
|
||||
|
||||
project_url = get_project_config().project_url
|
||||
|
||||
# Call the prompt API endpoint
|
||||
response = await call_post(
|
||||
client,
|
||||
f"{project_url}/prompt/continue-conversation",
|
||||
f"/v2/projects/{active_project.external_id}/prompt/continue-conversation",
|
||||
json=request.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
|
||||
@@ -46,9 +46,7 @@ async def recent_activity_prompt(
|
||||
|
||||
# Call the tool function - it returns a well-formatted string
|
||||
# Pass type as string values (not enum) to match the tool's expected input
|
||||
activity_summary = await recent_activity.fn(
|
||||
project=project, timeframe=timeframe, type="entity"
|
||||
)
|
||||
activity_summary = await recent_activity.fn(project=project, timeframe=timeframe, type="entity")
|
||||
|
||||
# Build the prompt response
|
||||
# The tool already returns formatted markdown, so we use it directly
|
||||
@@ -92,7 +90,7 @@ async def recent_activity_prompt(
|
||||
- summarizes [[Recent Work]]
|
||||
''',
|
||||
folder="insights",
|
||||
project="{project or 'default'}"
|
||||
project="{project or "default"}"
|
||||
)
|
||||
```
|
||||
""")
|
||||
|
||||
@@ -8,8 +8,9 @@ from typing import Annotated, Optional
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.prompt import SearchPromptRequest
|
||||
@@ -41,14 +42,17 @@ async def search_prompt(
|
||||
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
|
||||
|
||||
async with get_client() as client:
|
||||
config = ConfigManager().config
|
||||
active_project = await get_active_project(client, project=config.default_project)
|
||||
|
||||
# Create request model
|
||||
request = SearchPromptRequest(query=query, timeframe=timeframe)
|
||||
|
||||
project_url = get_project_config().project_url
|
||||
|
||||
# Call the prompt API endpoint
|
||||
response = await call_post(
|
||||
client, f"{project_url}/prompt/search", json=request.model_dump(exclude_none=True)
|
||||
client,
|
||||
f"/v2/projects/{active_project.external_id}/prompt/search",
|
||||
json=request.model_dump(exclude_none=True),
|
||||
)
|
||||
|
||||
# Extract the rendered prompt from the response
|
||||
|
||||
@@ -62,10 +62,9 @@ async def project_info(
|
||||
|
||||
async with get_client() as client:
|
||||
project_config = await get_active_project(client, project, context)
|
||||
project_url = project_config.permalink
|
||||
|
||||
# Call the API endpoint
|
||||
response = await call_get(client, f"{project_url}/project/info")
|
||||
response = await call_get(client, f"/v2/projects/{project_config.external_id}/info")
|
||||
|
||||
# Convert response to ProjectInfoResponse
|
||||
return ProjectInfoResponse.model_validate(response.json())
|
||||
|
||||
@@ -13,7 +13,7 @@ from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.view_note import view_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
from basic_memory.mcp.tools.search import search_notes
|
||||
from basic_memory.mcp.tools.search import search_notes, search_by_metadata
|
||||
from basic_memory.mcp.tools.canvas import canvas
|
||||
from basic_memory.mcp.tools.list_directory import list_directory
|
||||
from basic_memory.mcp.tools.edit_note import edit_note
|
||||
@@ -42,6 +42,7 @@ __all__ = [
|
||||
"read_note",
|
||||
"recent_activity",
|
||||
"search",
|
||||
"search_by_metadata",
|
||||
"search_notes",
|
||||
"view_note",
|
||||
"write_note",
|
||||
|
||||
@@ -256,7 +256,7 @@ async def edit_note(
|
||||
edit_data["expected_replacements"] = str(expected_replacements)
|
||||
|
||||
# Call the PATCH endpoint
|
||||
result = await knowledge_client.patch_entity(entity_id, edit_data)
|
||||
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
|
||||
|
||||
# Format summary
|
||||
summary = [
|
||||
|
||||
@@ -488,7 +488,9 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
return "\n".join(result_lines)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Directory move failed for '{identifier}' to '{destination_path}': {e}")
|
||||
logger.error(
|
||||
f"Directory move failed for '{identifier}' to '{destination_path}': {e}"
|
||||
)
|
||||
return f"""# Directory Move Failed
|
||||
|
||||
Error moving directory '{identifier}' to '{destination_path}': {str(e)}
|
||||
|
||||
@@ -144,7 +144,7 @@ async def recent_activity(
|
||||
)
|
||||
|
||||
# Get list of all projects
|
||||
response = await call_get(client, "/projects/projects")
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
project_list = ProjectList.model_validate(response.json())
|
||||
|
||||
projects_activity = {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Search tools for Basic Memory MCP server."""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
@@ -207,6 +207,9 @@ async def search_notes(
|
||||
types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
status: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> SearchResponse | str:
|
||||
"""Search across all content in the knowledge base with comprehensive syntax support.
|
||||
@@ -248,6 +251,27 @@ async def search_notes(
|
||||
- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
|
||||
- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
|
||||
- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
|
||||
- `search_notes("my-project", "query", tags=["security"])` - Filter by frontmatter tags
|
||||
- `search_notes("my-project", "query", status="in-progress")` - Filter by frontmatter status
|
||||
- `search_notes("my-project", "query", metadata_filters={"priority": {"$in": ["high"]}})`
|
||||
|
||||
### Structured Metadata Filters
|
||||
Filters are exact matches on frontmatter metadata. Supported forms:
|
||||
- Equality: `{"status": "in-progress"}`
|
||||
- Array contains (all): `{"tags": ["security", "oauth"]}`
|
||||
- Operators:
|
||||
- `$in`: `{"priority": {"$in": ["high", "critical"]}}`
|
||||
- `$gt`, `$gte`, `$lt`, `$lte`: `{"schema.confidence": {"$gt": 0.7}}`
|
||||
- `$between`: `{"schema.confidence": {"$between": [0.3, 0.6]}}`
|
||||
- Nested keys use dot notation (e.g., `"schema.confidence"`).
|
||||
|
||||
### Filter-only Searches
|
||||
You can pass an empty query string when only using structured filters:
|
||||
- `search_notes("my-project", "", metadata_filters={"type": "spec"})`
|
||||
|
||||
### Convenience Filters
|
||||
`tags` and `status` are shorthand for metadata_filters. If the same key exists in
|
||||
metadata_filters, that value wins.
|
||||
|
||||
### Advanced Pattern Examples
|
||||
- `search_notes("work-project", "project AND (meeting OR discussion)")` - Complex boolean logic
|
||||
@@ -265,6 +289,9 @@ async def search_notes(
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
|
||||
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
|
||||
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
|
||||
status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"]
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -355,6 +382,12 @@ async def search_notes(
|
||||
search_query.types = types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
search_query.metadata_filters = metadata_filters
|
||||
if tags:
|
||||
search_query.tags = tags
|
||||
if status:
|
||||
search_query.status = status
|
||||
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
@@ -387,3 +420,82 @@ async def search_notes(
|
||||
logger.error(f"Search failed for query '{query}': {e}, project: {active_project.name}")
|
||||
# Return formatted error message as string for better user experience
|
||||
return _format_search_error_response(active_project.name, str(e), query, search_type)
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Search entities by structured frontmatter metadata.",
|
||||
)
|
||||
async def search_by_metadata(
|
||||
filters: Dict[str, Any],
|
||||
project: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
context: Context | None = None,
|
||||
) -> SearchResponse | str:
|
||||
"""Search entities by structured frontmatter metadata.
|
||||
|
||||
Args:
|
||||
filters: Dictionary of metadata filters (e.g., {"status": "in-progress"})
|
||||
project: Project name to search in. Optional - server will resolve using hierarchy.
|
||||
limit: Maximum number of results to return
|
||||
offset: Number of results to skip (for pagination)
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
SearchResponse with results, or helpful error guidance if search fails
|
||||
"""
|
||||
if limit <= 0:
|
||||
return "# Error\n\n`limit` must be greater than 0."
|
||||
|
||||
# Build a structured-only search query
|
||||
search_query = SearchQuery()
|
||||
search_query.metadata_filters = filters
|
||||
search_query.entity_types = [SearchItemType.ENTITY]
|
||||
|
||||
# Convert offset/limit to page/page_size (API uses paging)
|
||||
page_size = limit
|
||||
page = (offset // limit) + 1
|
||||
offset_within_page = offset % limit
|
||||
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
logger.info(
|
||||
f"Structured search in project {active_project.name} filters={filters} limit={limit} offset={offset}"
|
||||
)
|
||||
|
||||
try:
|
||||
from basic_memory.mcp.clients import SearchClient
|
||||
|
||||
search_client = SearchClient(client, active_project.external_id)
|
||||
result = await search_client.search(
|
||||
search_query.model_dump(),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# Apply offset within page, fetch next page if needed
|
||||
if offset_within_page:
|
||||
remaining = result.results[offset_within_page:]
|
||||
if len(remaining) < limit:
|
||||
next_page = page + 1
|
||||
extra = await search_client.search(
|
||||
search_query.model_dump(),
|
||||
page=next_page,
|
||||
page_size=page_size,
|
||||
)
|
||||
remaining.extend(extra.results[: max(0, limit - len(remaining))])
|
||||
result = SearchResponse(
|
||||
results=remaining[:limit],
|
||||
current_page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Metadata search failed for filters '{filters}': {e}, project: {active_project.name}"
|
||||
)
|
||||
return _format_search_error_response(
|
||||
active_project.name, str(e), str(filters), "metadata"
|
||||
)
|
||||
|
||||
@@ -159,7 +159,7 @@ async def write_note(
|
||||
logger.debug(f"Attempting to create entity permalink={entity.permalink}")
|
||||
action = "Created" # Default to created
|
||||
try:
|
||||
result = await knowledge_client.create_entity(entity.model_dump())
|
||||
result = await knowledge_client.create_entity(entity.model_dump(), fast=False)
|
||||
action = "Created"
|
||||
except Exception as e:
|
||||
# If creation failed due to conflict (already exists), try to update
|
||||
@@ -175,7 +175,9 @@ async def write_note(
|
||||
"Entity permalink is required for updates"
|
||||
) # pragma: no cover
|
||||
entity_id = await knowledge_client.resolve_entity(entity.permalink)
|
||||
result = await knowledge_client.update_entity(entity_id, entity.model_dump())
|
||||
result = await knowledge_client.update_entity(
|
||||
entity_id, entity.model_dump(), fast=False
|
||||
)
|
||||
action = "Updated"
|
||||
except Exception as update_error: # pragma: no cover
|
||||
# Re-raise the original error if update also fails
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Helpers for parsing structured metadata filters for search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
from typing import Any, Iterable, List
|
||||
|
||||
|
||||
_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$")
|
||||
_NUMERIC_RE = re.compile(r"^-?\d+(\.\d+)?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedMetadataFilter:
|
||||
"""Normalized metadata filter for SQL generation."""
|
||||
|
||||
path_parts: List[str]
|
||||
op: str
|
||||
value: Any
|
||||
comparison: str | None = None # "numeric" or "text" for comparisons
|
||||
|
||||
|
||||
def _is_numeric_value(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return bool(_NUMERIC_RE.match(value.strip()))
|
||||
return False
|
||||
|
||||
|
||||
def _is_numeric_collection(values: Iterable[Any]) -> bool:
|
||||
return all(_is_numeric_value(v) for v in values)
|
||||
|
||||
|
||||
def _normalize_scalar(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, bool):
|
||||
return str(value)
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
|
||||
def parse_metadata_filters(filters: dict[str, Any]) -> List[ParsedMetadataFilter]:
|
||||
"""Parse metadata filters into normalized clauses.
|
||||
|
||||
Supported forms:
|
||||
- {"status": "in-progress"}
|
||||
- {"tags": ["security", "oauth"]} # array contains all
|
||||
- {"priority": {"$in": ["high", "critical"]}}
|
||||
- {"schema.confidence": {"$gt": 0.7}}
|
||||
- {"schema.confidence": {"$between": [0.3, 0.6]}}
|
||||
"""
|
||||
parsed: List[ParsedMetadataFilter] = []
|
||||
|
||||
for raw_key, raw_value in (filters or {}).items():
|
||||
if not isinstance(raw_key, str) or not raw_key.strip():
|
||||
raise ValueError("metadata filter keys must be non-empty strings")
|
||||
key = raw_key.strip()
|
||||
if not _KEY_RE.match(key):
|
||||
raise ValueError(f"Unsupported metadata filter key: {raw_key}")
|
||||
|
||||
path_parts = key.split(".")
|
||||
|
||||
# Operator form
|
||||
if isinstance(raw_value, dict):
|
||||
if len(raw_value) != 1:
|
||||
raise ValueError(f"Invalid metadata filter for '{raw_key}': {raw_value}")
|
||||
op, value = next(iter(raw_value.items()))
|
||||
|
||||
if op == "$in":
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError(f"$in requires a non-empty list for '{raw_key}'")
|
||||
parsed.append(
|
||||
ParsedMetadataFilter(path_parts, "in", [_normalize_scalar(v) for v in value])
|
||||
)
|
||||
continue
|
||||
|
||||
if op in {"$gt", "$gte", "$lt", "$lte"}:
|
||||
normalized = _normalize_scalar(value)
|
||||
comparison = "numeric" if _is_numeric_value(normalized) else "text"
|
||||
parsed.append(
|
||||
ParsedMetadataFilter(path_parts, op.lstrip("$"), normalized, comparison)
|
||||
)
|
||||
continue
|
||||
|
||||
if op == "$between":
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise ValueError(f"$between requires [min, max] for '{raw_key}'")
|
||||
normalized = [_normalize_scalar(v) for v in value]
|
||||
comparison = "numeric" if _is_numeric_collection(normalized) else "text"
|
||||
parsed.append(ParsedMetadataFilter(path_parts, "between", normalized, comparison))
|
||||
continue
|
||||
|
||||
raise ValueError(f"Unsupported operator '{op}' in metadata filter for '{raw_key}'")
|
||||
|
||||
# Array contains (all)
|
||||
if isinstance(raw_value, list):
|
||||
if not raw_value:
|
||||
raise ValueError(f"Empty list not allowed for metadata filter '{raw_key}'")
|
||||
parsed.append(
|
||||
ParsedMetadataFilter(
|
||||
path_parts, "contains", [_normalize_scalar(v) for v in raw_value]
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Simple equality
|
||||
parsed.append(ParsedMetadataFilter(path_parts, "eq", _normalize_scalar(raw_value)))
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def build_sqlite_json_path(parts: List[str]) -> str:
|
||||
"""Build a SQLite JSON path for json_extract/json_each."""
|
||||
path = "$"
|
||||
for part in parts:
|
||||
path += f'."{part}"'
|
||||
return path
|
||||
|
||||
|
||||
def build_postgres_json_path(parts: List[str]) -> str:
|
||||
"""Build a Postgres JSON path for #>>/#> operators."""
|
||||
return "{" + ",".join(parts) + "}"
|
||||
@@ -12,6 +12,10 @@ from sqlalchemy import text
|
||||
from basic_memory import db
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.search_repository_base import SearchRepositoryBase
|
||||
from basic_memory.repository.metadata_filters import (
|
||||
parse_metadata_filters,
|
||||
build_postgres_json_path,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@@ -215,6 +219,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -222,6 +227,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
from_clause = "search_index"
|
||||
|
||||
# Handle text search for title and content using tsvector
|
||||
if search_text:
|
||||
@@ -233,18 +239,22 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
# Use @@ operator for tsvector matching
|
||||
conditions.append("textsearchable_index_col @@ to_tsquery('english', :text)")
|
||||
conditions.append(
|
||||
"search_index.textsearchable_index_col @@ to_tsquery('english', :text)"
|
||||
)
|
||||
|
||||
# Handle title search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("to_tsvector('english', title) @@ to_tsquery('english', :title_text)")
|
||||
conditions.append(
|
||||
"to_tsvector('english', search_index.title) @@ to_tsquery('english', :title_text)"
|
||||
)
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
conditions.append("search_index.permalink = :permalink")
|
||||
|
||||
# Handle permalink pattern match
|
||||
if permalink_match:
|
||||
@@ -255,14 +265,14 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
# Convert * to % for SQL LIKE
|
||||
permalink_pattern = permalink_text.replace("*", "%")
|
||||
params["permalink"] = permalink_pattern
|
||||
conditions.append("permalink LIKE :permalink")
|
||||
conditions.append("search_index.permalink LIKE :permalink")
|
||||
else:
|
||||
conditions.append("permalink = :permalink")
|
||||
conditions.append("search_index.permalink = :permalink")
|
||||
|
||||
# Handle search item type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
conditions.append(f"search_index.type IN ({type_list})")
|
||||
|
||||
# Handle entity type filter using JSONB containment
|
||||
if types:
|
||||
@@ -270,19 +280,91 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
type_conditions = []
|
||||
for entity_type in types:
|
||||
# Create JSONB containment condition for each type
|
||||
type_conditions.append(f'metadata @> \'{{"entity_type": "{entity_type}"}}\'')
|
||||
type_conditions.append(
|
||||
f'search_index.metadata @> \'{{"entity_type": "{entity_type}"}}\''
|
||||
)
|
||||
conditions.append(f"({' OR '.join(type_conditions)})")
|
||||
|
||||
# Handle date filter
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("created_at > :after_date")
|
||||
conditions.append("search_index.created_at > :after_date")
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
order_by_clause = ", search_index.updated_at DESC"
|
||||
|
||||
# Handle structured metadata filters (frontmatter)
|
||||
if metadata_filters:
|
||||
parsed_filters = parse_metadata_filters(metadata_filters)
|
||||
from_clause = "search_index JOIN entity ON search_index.entity_id = entity.id"
|
||||
metadata_expr = "entity.entity_metadata::jsonb"
|
||||
|
||||
for idx, filt in enumerate(parsed_filters):
|
||||
path = build_postgres_json_path(filt.path_parts)
|
||||
text_expr = f"({metadata_expr} #>> '{path}')"
|
||||
json_expr = f"({metadata_expr} #> '{path}')"
|
||||
|
||||
if filt.op == "eq":
|
||||
value_param = f"meta_val_{idx}"
|
||||
params[value_param] = filt.value
|
||||
conditions.append(f"{text_expr} = :{value_param}")
|
||||
continue
|
||||
|
||||
if filt.op == "in":
|
||||
placeholders = []
|
||||
for j, val in enumerate(filt.value):
|
||||
value_param = f"meta_val_{idx}_{j}"
|
||||
params[value_param] = val
|
||||
placeholders.append(f":{value_param}")
|
||||
conditions.append(f"{text_expr} IN ({', '.join(placeholders)})")
|
||||
continue
|
||||
|
||||
if filt.op == "contains":
|
||||
import json as _json
|
||||
|
||||
base_param = f"meta_val_{idx}"
|
||||
tag_conditions = []
|
||||
# Require all values to be present
|
||||
for j, val in enumerate(filt.value):
|
||||
tag_param = f"{base_param}_{j}"
|
||||
params[tag_param] = _json.dumps([val])
|
||||
like_param = f"{base_param}_{j}_like"
|
||||
params[like_param] = f'%"{val}"%'
|
||||
like_param_single = f"{base_param}_{j}_like_single"
|
||||
params[like_param_single] = f"%'{val}'%"
|
||||
tag_conditions.append(
|
||||
f"({json_expr} @> :{tag_param}::jsonb "
|
||||
f"OR {text_expr} LIKE :{like_param} "
|
||||
f"OR {text_expr} LIKE :{like_param_single})"
|
||||
)
|
||||
conditions.append(" AND ".join(tag_conditions))
|
||||
continue
|
||||
|
||||
if filt.op in {"gt", "gte", "lt", "lte", "between"}:
|
||||
if filt.comparison == "numeric":
|
||||
numeric_expr = (
|
||||
f"CASE WHEN ({text_expr}) ~ '^-?\\\\d+(\\\\.\\\\d+)?$' "
|
||||
f"THEN ({text_expr})::double precision END"
|
||||
)
|
||||
compare_expr = numeric_expr
|
||||
else:
|
||||
compare_expr = text_expr
|
||||
|
||||
if filt.op == "between":
|
||||
min_param = f"meta_val_{idx}_min"
|
||||
max_param = f"meta_val_{idx}_max"
|
||||
params[min_param] = filt.value[0]
|
||||
params[max_param] = filt.value[1]
|
||||
conditions.append(f"{compare_expr} BETWEEN :{min_param} AND :{max_param}")
|
||||
else:
|
||||
value_param = f"meta_val_{idx}"
|
||||
params[value_param] = filt.value
|
||||
operator = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[filt.op]
|
||||
conditions.append(f"{compare_expr} {operator} :{value_param}")
|
||||
continue
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
conditions.append("search_index.project_id = :project_id")
|
||||
|
||||
# set limit and offset
|
||||
params["limit"] = limit
|
||||
@@ -294,31 +376,33 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
# Build SQL with ts_rank() for scoring
|
||||
# Note: If no text search, score will be NULL, so we use COALESCE to default to 0
|
||||
if search_text and search_text.strip() and search_text.strip() != "*":
|
||||
score_expr = "ts_rank(textsearchable_index_col, to_tsquery('english', :text))"
|
||||
score_expr = (
|
||||
"ts_rank(search_index.textsearchable_index_col, to_tsquery('english', :text))"
|
||||
)
|
||||
else:
|
||||
score_expr = "0"
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
search_index.project_id,
|
||||
search_index.id,
|
||||
search_index.title,
|
||||
search_index.permalink,
|
||||
search_index.file_path,
|
||||
search_index.type,
|
||||
search_index.metadata,
|
||||
search_index.from_id,
|
||||
search_index.to_id,
|
||||
search_index.relation_type,
|
||||
search_index.entity_id,
|
||||
search_index.content_snippet,
|
||||
search_index.category,
|
||||
search_index.created_at,
|
||||
search_index.updated_at,
|
||||
{score_expr} as score
|
||||
FROM search_index
|
||||
FROM {from_clause}
|
||||
WHERE {where_clause}
|
||||
ORDER BY score DESC, id ASC {order_by_clause}
|
||||
ORDER BY score DESC, search_index.id ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
"""
|
||||
|
||||
@@ -40,6 +40,7 @@ class SearchRepository(Protocol):
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
|
||||
@@ -78,6 +78,7 @@ class SearchRepositoryBase(ABC):
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -91,6 +92,7 @@ class SearchRepositoryBase(ABC):
|
||||
types: Filter by entity types (from metadata.entity_type)
|
||||
after_date: Filter by created_at > after_date
|
||||
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
|
||||
metadata_filters: Structured frontmatter metadata filters
|
||||
limit: Maximum results to return
|
||||
offset: Number of results to skip
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from basic_memory import db
|
||||
from basic_memory.models.search import CREATE_SEARCH_INDEX
|
||||
from basic_memory.repository.search_index_row import SearchIndexRow
|
||||
from basic_memory.repository.search_repository_base import SearchRepositoryBase
|
||||
from basic_memory.repository.metadata_filters import parse_metadata_filters, build_sqlite_json_path
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@@ -26,6 +27,17 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
- Prefix wildcard matching with *
|
||||
"""
|
||||
|
||||
def __init__(self, session_maker, project_id: int):
|
||||
super().__init__(session_maker, project_id)
|
||||
self._entity_columns: set[str] | None = None
|
||||
|
||||
async def _get_entity_columns(self) -> set[str]:
|
||||
if self._entity_columns is None:
|
||||
async with db.scoped_session(self.session_maker) as session:
|
||||
result = await session.execute(text("PRAGMA table_info(entity)"))
|
||||
self._entity_columns = {row[1] for row in result.fetchall()}
|
||||
return self._entity_columns
|
||||
|
||||
async def init_search_index(self):
|
||||
"""Create FTS5 virtual table for search if it doesn't exist.
|
||||
|
||||
@@ -287,6 +299,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[SearchIndexRow]:
|
||||
@@ -294,6 +307,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
conditions = []
|
||||
params = {}
|
||||
order_by_clause = ""
|
||||
from_clause = "search_index"
|
||||
|
||||
# Handle text search for title and content
|
||||
if search_text:
|
||||
@@ -305,18 +319,20 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
|
||||
processed_text = self._prepare_search_term(search_text.strip())
|
||||
params["text"] = processed_text
|
||||
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
|
||||
conditions.append(
|
||||
"(search_index.title MATCH :text OR search_index.content_stems MATCH :text)"
|
||||
)
|
||||
|
||||
# Handle title match search
|
||||
if title:
|
||||
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
|
||||
params["title_text"] = title_text
|
||||
conditions.append("title MATCH :title_text")
|
||||
conditions.append("search_index.title MATCH :title_text")
|
||||
|
||||
# Handle permalink exact search
|
||||
if permalink:
|
||||
params["permalink"] = permalink
|
||||
conditions.append("permalink = :permalink")
|
||||
conditions.append("search_index.permalink = :permalink")
|
||||
|
||||
# Handle permalink match search, supports *
|
||||
if permalink_match:
|
||||
@@ -325,38 +341,122 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
permalink_text = permalink_match.lower().strip()
|
||||
params["permalink"] = permalink_text
|
||||
if "*" in permalink_match:
|
||||
conditions.append("permalink GLOB :permalink")
|
||||
conditions.append("search_index.permalink GLOB :permalink")
|
||||
else:
|
||||
# For exact matches without *, we can use FTS5 MATCH
|
||||
# but only prepare the term if it doesn't look like a path
|
||||
if "/" in permalink_text:
|
||||
conditions.append("permalink = :permalink")
|
||||
conditions.append("search_index.permalink = :permalink")
|
||||
else:
|
||||
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
|
||||
params["permalink"] = permalink_text
|
||||
conditions.append("permalink MATCH :permalink")
|
||||
conditions.append("search_index.permalink MATCH :permalink")
|
||||
|
||||
# Handle entity type filter
|
||||
if search_item_types:
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"type IN ({type_list})")
|
||||
conditions.append(f"search_index.type IN ({type_list})")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t}'" for t in types)
|
||||
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
|
||||
conditions.append(
|
||||
f"json_extract(search_index.metadata, '$.entity_type') IN ({type_list})"
|
||||
)
|
||||
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
if after_date:
|
||||
params["after_date"] = after_date
|
||||
conditions.append("datetime(created_at) > datetime(:after_date)")
|
||||
conditions.append("datetime(search_index.created_at) > datetime(:after_date)")
|
||||
|
||||
# order by most recent first
|
||||
order_by_clause = ", updated_at DESC"
|
||||
order_by_clause = ", search_index.updated_at DESC"
|
||||
|
||||
# Handle structured metadata filters (frontmatter)
|
||||
if metadata_filters:
|
||||
parsed_filters = parse_metadata_filters(metadata_filters)
|
||||
from_clause = "search_index JOIN entity ON search_index.entity_id = entity.id"
|
||||
entity_columns = await self._get_entity_columns()
|
||||
|
||||
for idx, filt in enumerate(parsed_filters):
|
||||
path_param = f"meta_path_{idx}"
|
||||
extract_expr = None
|
||||
use_tags_column = False
|
||||
|
||||
if filt.path_parts == ["status"] and "frontmatter_status" in entity_columns:
|
||||
extract_expr = "entity.frontmatter_status"
|
||||
elif filt.path_parts == ["type"] and "frontmatter_type" in entity_columns:
|
||||
extract_expr = "entity.frontmatter_type"
|
||||
elif filt.path_parts == ["tags"] and "tags_json" in entity_columns:
|
||||
extract_expr = "entity.tags_json"
|
||||
use_tags_column = True
|
||||
|
||||
if extract_expr is None:
|
||||
params[path_param] = build_sqlite_json_path(filt.path_parts)
|
||||
extract_expr = f"json_extract(entity.entity_metadata, :{path_param})"
|
||||
|
||||
if filt.op == "eq":
|
||||
value_param = f"meta_val_{idx}"
|
||||
params[value_param] = filt.value
|
||||
conditions.append(f"{extract_expr} = :{value_param}")
|
||||
continue
|
||||
|
||||
if filt.op == "in":
|
||||
placeholders = []
|
||||
for j, val in enumerate(filt.value):
|
||||
value_param = f"meta_val_{idx}_{j}"
|
||||
params[value_param] = val
|
||||
placeholders.append(f":{value_param}")
|
||||
conditions.append(f"{extract_expr} IN ({', '.join(placeholders)})")
|
||||
continue
|
||||
|
||||
if filt.op == "contains":
|
||||
tag_conditions = []
|
||||
for j, val in enumerate(filt.value):
|
||||
value_param = f"meta_val_{idx}_{j}"
|
||||
params[value_param] = val
|
||||
like_param = f"{value_param}_like"
|
||||
params[like_param] = f'%"{val}"%'
|
||||
like_param_single = f"{value_param}_like_single"
|
||||
params[like_param_single] = f"%'{val}'%"
|
||||
json_each_expr = (
|
||||
"json_each(entity.tags_json)"
|
||||
if use_tags_column
|
||||
else f"json_each(entity.entity_metadata, :{path_param})"
|
||||
)
|
||||
tag_conditions.append(
|
||||
"("
|
||||
f"EXISTS (SELECT 1 FROM {json_each_expr} WHERE value = :{value_param}) "
|
||||
f"OR {extract_expr} LIKE :{like_param} "
|
||||
f"OR {extract_expr} LIKE :{like_param_single}"
|
||||
")"
|
||||
)
|
||||
conditions.append(" AND ".join(tag_conditions))
|
||||
continue
|
||||
|
||||
if filt.op in {"gt", "gte", "lt", "lte", "between"}:
|
||||
compare_expr = (
|
||||
f"CAST({extract_expr} AS REAL)"
|
||||
if filt.comparison == "numeric"
|
||||
else extract_expr
|
||||
)
|
||||
|
||||
if filt.op == "between":
|
||||
min_param = f"meta_val_{idx}_min"
|
||||
max_param = f"meta_val_{idx}_max"
|
||||
params[min_param] = filt.value[0]
|
||||
params[max_param] = filt.value[1]
|
||||
conditions.append(f"{compare_expr} BETWEEN :{min_param} AND :{max_param}")
|
||||
else:
|
||||
value_param = f"meta_val_{idx}"
|
||||
params[value_param] = filt.value
|
||||
operator = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[filt.op]
|
||||
conditions.append(f"{compare_expr} {operator} :{value_param}")
|
||||
continue
|
||||
|
||||
# Always filter by project_id
|
||||
params["project_id"] = self.project_id
|
||||
conditions.append("project_id = :project_id")
|
||||
conditions.append("search_index.project_id = :project_id")
|
||||
|
||||
# set limit on search query
|
||||
params["limit"] = limit
|
||||
@@ -367,23 +467,23 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
|
||||
sql = f"""
|
||||
SELECT
|
||||
project_id,
|
||||
id,
|
||||
title,
|
||||
permalink,
|
||||
file_path,
|
||||
type,
|
||||
metadata,
|
||||
from_id,
|
||||
to_id,
|
||||
relation_type,
|
||||
entity_id,
|
||||
content_snippet,
|
||||
category,
|
||||
created_at,
|
||||
updated_at,
|
||||
search_index.project_id,
|
||||
search_index.id,
|
||||
search_index.title,
|
||||
search_index.permalink,
|
||||
search_index.file_path,
|
||||
search_index.type,
|
||||
search_index.metadata,
|
||||
search_index.from_id,
|
||||
search_index.to_id,
|
||||
search_index.relation_type,
|
||||
search_index.entity_id,
|
||||
search_index.content_snippet,
|
||||
search_index.category,
|
||||
search_index.created_at,
|
||||
search_index.updated_at,
|
||||
bm25(search_index) as score
|
||||
FROM search_index
|
||||
FROM {from_clause}
|
||||
WHERE {where_clause}
|
||||
ORDER BY score ASC {order_by_clause}
|
||||
LIMIT :limit
|
||||
|
||||
@@ -25,7 +25,7 @@ class CloudProject(BaseModel):
|
||||
|
||||
|
||||
class CloudProjectList(BaseModel):
|
||||
"""Response from /proxy/projects/projects endpoint."""
|
||||
"""Response from /proxy/v2/projects endpoint."""
|
||||
|
||||
projects: list[CloudProject] = Field(default_factory=list, description="List of cloud projects")
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ class EntitySummary(BaseModel):
|
||||
"""Simplified entity representation."""
|
||||
|
||||
type: Literal["entity"] = "entity"
|
||||
external_id: str # UUID for v2 API routing
|
||||
entity_id: int # Database ID for v2 API consistency
|
||||
permalink: Optional[str]
|
||||
title: str
|
||||
@@ -150,8 +151,10 @@ class RelationSummary(BaseModel):
|
||||
relation_type: str
|
||||
from_entity: Optional[str] = None
|
||||
from_entity_id: Optional[int] = None # ID of source entity
|
||||
from_entity_external_id: Optional[str] = None # UUID of source entity for v2 API routing
|
||||
to_entity: Optional[str] = None
|
||||
to_entity_id: Optional[int] = None # ID of target entity
|
||||
to_entity_external_id: Optional[str] = None # UUID of target entity for v2 API routing
|
||||
created_at: Annotated[
|
||||
datetime, Field(json_schema_extra={"type": "string", "format": "date-time"})
|
||||
]
|
||||
@@ -167,6 +170,7 @@ class ObservationSummary(BaseModel):
|
||||
type: Literal["observation"] = "observation"
|
||||
observation_id: int # Database ID for v2 API consistency
|
||||
entity_id: Optional[int] = None # ID of the entity this observation belongs to
|
||||
entity_external_id: Optional[str] = None # UUID of parent entity for v2 API routing
|
||||
title: str
|
||||
file_path: str
|
||||
permalink: str
|
||||
|
||||
@@ -6,7 +6,7 @@ The search system supports three primary modes:
|
||||
3. Full-text search across content
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Union
|
||||
from typing import Optional, List, Union, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, field_validator
|
||||
@@ -29,11 +29,15 @@ class SearchQuery(BaseModel):
|
||||
- permalink: Exact permalink match
|
||||
- permalink_match: Path pattern with *
|
||||
- text: Full-text search of title/content (supports boolean operators: AND, OR, NOT)
|
||||
- title: Title only search
|
||||
|
||||
Optionally filter results by:
|
||||
- types: Limit to specific item types
|
||||
- entity_types: Limit to specific entity types
|
||||
- types: Limit to specific entity types (frontmatter "type")
|
||||
- entity_types: Limit to search item types (entity/observation/relation)
|
||||
- after_date: Only items after date
|
||||
- metadata_filters: Structured frontmatter filters (field -> value)
|
||||
- tags: Convenience frontmatter tag filter
|
||||
- status: Convenience frontmatter status filter
|
||||
|
||||
Boolean search examples:
|
||||
- "python AND flask" - Find items with both terms
|
||||
@@ -52,6 +56,9 @@ class SearchQuery(BaseModel):
|
||||
types: Optional[List[str]] = None # Filter by type
|
||||
entity_types: Optional[List[SearchItemType]] = None # Filter by entity type
|
||||
after_date: Optional[Union[datetime, str]] = None # Time-based filter
|
||||
metadata_filters: Optional[dict[str, Any]] = None # Structured frontmatter filters
|
||||
tags: Optional[List[str]] = None # Convenience tag filter
|
||||
status: Optional[str] = None # Convenience status filter
|
||||
|
||||
@field_validator("after_date")
|
||||
@classmethod
|
||||
@@ -62,14 +69,23 @@ class SearchQuery(BaseModel):
|
||||
return v
|
||||
|
||||
def no_criteria(self) -> bool:
|
||||
text_is_empty = self.text is None or (isinstance(self.text, str) and not self.text.strip())
|
||||
metadata_is_empty = not self.metadata_filters
|
||||
tags_is_empty = not self.tags
|
||||
status_is_empty = self.status is None or (isinstance(self.status, str) and not self.status)
|
||||
types_is_empty = not self.types
|
||||
entity_types_is_empty = not self.entity_types
|
||||
return (
|
||||
self.permalink is None
|
||||
and self.permalink_match is None
|
||||
and self.title is None
|
||||
and self.text is None
|
||||
and text_is_empty
|
||||
and self.after_date is None
|
||||
and self.types is None
|
||||
and self.entity_types is None
|
||||
and types_is_empty
|
||||
and entity_types_is_empty
|
||||
and metadata_is_empty
|
||||
and tags_is_empty
|
||||
and status_is_empty
|
||||
)
|
||||
|
||||
def has_boolean_operators(self) -> bool:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Service for managing entities in the database."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Sequence, Tuple, Union
|
||||
|
||||
@@ -17,7 +18,7 @@ from basic_memory.file_utils import (
|
||||
dump_frontmatter,
|
||||
)
|
||||
from basic_memory.markdown import EntityMarkdown
|
||||
from basic_memory.markdown.entity_parser import EntityParser
|
||||
from basic_memory.markdown.entity_parser import EntityParser, normalize_frontmatter_metadata
|
||||
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
|
||||
from basic_memory.models import Entity as EntityModel
|
||||
from basic_memory.models import Observation, Relation
|
||||
@@ -167,6 +168,25 @@ class EntityService(BaseService[EntityModel]):
|
||||
|
||||
return permalink
|
||||
|
||||
def _build_frontmatter_markdown(
|
||||
self, title: str, entity_type: str, permalink: str
|
||||
) -> EntityMarkdown:
|
||||
"""Build a minimal EntityMarkdown object for permalink resolution."""
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter
|
||||
|
||||
frontmatter_metadata = {
|
||||
"title": title,
|
||||
"type": entity_type,
|
||||
"permalink": permalink,
|
||||
}
|
||||
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
|
||||
return EntityMarkdown(
|
||||
frontmatter=frontmatter_obj,
|
||||
content="",
|
||||
observations=[],
|
||||
relations=[],
|
||||
)
|
||||
|
||||
async def create_or_update_entity(self, schema: EntitySchema) -> Tuple[EntityModel, bool]:
|
||||
"""Create new entity or update existing one.
|
||||
Returns: (entity, is_new) where is_new is True if a new entity was created
|
||||
@@ -210,20 +230,8 @@ class EntityService(BaseService[EntityModel]):
|
||||
schema.entity_type = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
# Create a minimal EntityMarkdown object for permalink resolution
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter
|
||||
|
||||
frontmatter_metadata = {
|
||||
"title": schema.title,
|
||||
"type": schema.entity_type,
|
||||
"permalink": content_frontmatter["permalink"],
|
||||
}
|
||||
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
|
||||
content_markdown = EntityMarkdown(
|
||||
frontmatter=frontmatter_obj,
|
||||
content="", # content not needed for permalink resolution
|
||||
observations=[],
|
||||
relations=[],
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
schema.title, schema.entity_type, content_frontmatter["permalink"]
|
||||
)
|
||||
|
||||
# Get unique permalink (prioritizing content frontmatter) unless disabled
|
||||
@@ -248,11 +256,8 @@ class EntityService(BaseService[EntityModel]):
|
||||
content=final_content,
|
||||
)
|
||||
|
||||
# create entity
|
||||
created = await self.create_entity_from_markdown(file_path, entity_markdown)
|
||||
|
||||
# add relations
|
||||
entity = await self.update_entity_relations(created.file_path, entity_markdown)
|
||||
# create entity and relations
|
||||
entity = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=True)
|
||||
|
||||
# Set final checksum to mark complete
|
||||
return await self.repository.update(entity.id, {"checksum": checksum})
|
||||
@@ -283,20 +288,8 @@ class EntityService(BaseService[EntityModel]):
|
||||
schema.entity_type = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
# Create a minimal EntityMarkdown object for permalink resolution
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter
|
||||
|
||||
frontmatter_metadata = {
|
||||
"title": schema.title,
|
||||
"type": schema.entity_type,
|
||||
"permalink": content_frontmatter["permalink"],
|
||||
}
|
||||
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
|
||||
content_markdown = EntityMarkdown(
|
||||
frontmatter=frontmatter_obj,
|
||||
content="", # content not needed for permalink resolution
|
||||
observations=[],
|
||||
relations=[],
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
schema.title, schema.entity_type, content_frontmatter["permalink"]
|
||||
)
|
||||
|
||||
# Check if we need to update the permalink based on content frontmatter (unless disabled)
|
||||
@@ -333,17 +326,179 @@ class EntityService(BaseService[EntityModel]):
|
||||
content=final_content,
|
||||
)
|
||||
|
||||
# update entity in db
|
||||
entity = await self.update_entity_and_observations(file_path, entity_markdown)
|
||||
|
||||
# add relations
|
||||
await self.update_entity_relations(file_path.as_posix(), entity_markdown)
|
||||
# update entity and relations
|
||||
entity = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=False)
|
||||
|
||||
# Set final checksum to match file
|
||||
entity = await self.repository.update(entity.id, {"checksum": checksum})
|
||||
|
||||
return entity
|
||||
|
||||
async def fast_write_entity(
|
||||
self,
|
||||
schema: EntitySchema,
|
||||
external_id: Optional[str] = None,
|
||||
) -> EntityModel:
|
||||
"""Write file and upsert a minimal entity row for fast responses."""
|
||||
logger.debug(
|
||||
"Fast-writing entity",
|
||||
title=schema.title,
|
||||
external_id=external_id,
|
||||
content_type=schema.content_type,
|
||||
)
|
||||
|
||||
# --- Identity & File Path ---
|
||||
existing = await self.repository.get_by_external_id(external_id) if external_id else None
|
||||
|
||||
# Trigger: external_id already exists
|
||||
# Why: avoid duplicate entities when title-derived paths change
|
||||
# Outcome: update in-place and keep the existing file path
|
||||
file_path = Path(existing.file_path) if existing else Path(schema.file_path)
|
||||
|
||||
if not existing and await self.file_service.exists(file_path):
|
||||
raise EntityCreationError(
|
||||
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
|
||||
)
|
||||
|
||||
# --- Frontmatter Overrides ---
|
||||
content_markdown = None
|
||||
if schema.content and has_frontmatter(schema.content):
|
||||
content_frontmatter = parse_frontmatter(schema.content)
|
||||
|
||||
if "type" in content_frontmatter:
|
||||
schema.entity_type = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
schema.title, schema.entity_type, content_frontmatter["permalink"]
|
||||
)
|
||||
|
||||
# --- Permalink Resolution ---
|
||||
if self.app_config and self.app_config.disable_permalinks:
|
||||
schema._permalink = ""
|
||||
else:
|
||||
if existing and not (content_markdown and content_markdown.frontmatter.permalink):
|
||||
schema._permalink = existing.permalink or await self.resolve_permalink(
|
||||
file_path, skip_conflict_check=True
|
||||
)
|
||||
else:
|
||||
schema._permalink = await self.resolve_permalink(
|
||||
file_path, content_markdown, skip_conflict_check=True
|
||||
)
|
||||
|
||||
# --- File Write ---
|
||||
post = await schema_to_markdown(schema)
|
||||
final_content = dump_frontmatter(post)
|
||||
checksum = await self.file_service.write_file(file_path, final_content)
|
||||
|
||||
# --- Minimal DB Upsert ---
|
||||
metadata = normalize_frontmatter_metadata(post.metadata or {})
|
||||
entity_metadata = {k: v for k, v in metadata.items() if v is not None}
|
||||
update_data = {
|
||||
"title": schema.title,
|
||||
"entity_type": schema.entity_type,
|
||||
"file_path": file_path.as_posix(),
|
||||
"content_type": schema.content_type,
|
||||
"entity_metadata": entity_metadata or None,
|
||||
"permalink": schema.permalink,
|
||||
"checksum": checksum,
|
||||
"updated_at": datetime.now().astimezone(),
|
||||
}
|
||||
|
||||
if existing:
|
||||
updated = await self.repository.update(existing.id, update_data)
|
||||
if not updated:
|
||||
raise ValueError(f"Failed to update entity in database: {existing.id}")
|
||||
return updated
|
||||
|
||||
create_data = {
|
||||
**update_data,
|
||||
"external_id": external_id,
|
||||
}
|
||||
return await self.repository.create(create_data)
|
||||
|
||||
async def fast_edit_entity(
|
||||
self,
|
||||
entity: EntityModel,
|
||||
operation: str,
|
||||
content: str,
|
||||
section: Optional[str] = None,
|
||||
find_text: Optional[str] = None,
|
||||
expected_replacements: int = 1,
|
||||
) -> EntityModel:
|
||||
"""Edit an entity quickly and defer full indexing to background."""
|
||||
logger.debug(f"Fast editing entity: {entity.external_id}, operation: {operation}")
|
||||
|
||||
# --- File Edit ---
|
||||
file_path = Path(entity.file_path)
|
||||
current_content, _ = await self.file_service.read_file(file_path)
|
||||
new_content = self.apply_edit_operation(
|
||||
current_content, operation, content, section, find_text, expected_replacements
|
||||
)
|
||||
checksum = await self.file_service.write_file(file_path, new_content)
|
||||
|
||||
# --- Frontmatter Overrides ---
|
||||
update_data = {
|
||||
"checksum": checksum,
|
||||
"updated_at": datetime.now().astimezone(),
|
||||
}
|
||||
content_markdown = None
|
||||
if has_frontmatter(new_content):
|
||||
content_frontmatter = parse_frontmatter(new_content)
|
||||
|
||||
if "title" in content_frontmatter:
|
||||
update_data["title"] = content_frontmatter["title"]
|
||||
if "type" in content_frontmatter:
|
||||
update_data["entity_type"] = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
update_data.get("title", entity.title),
|
||||
update_data.get("entity_type", entity.entity_type),
|
||||
content_frontmatter["permalink"],
|
||||
)
|
||||
|
||||
metadata = normalize_frontmatter_metadata(content_frontmatter or {})
|
||||
update_data["entity_metadata"] = {k: v for k, v in metadata.items() if v is not None}
|
||||
|
||||
# --- Permalink Resolution ---
|
||||
if self.app_config and self.app_config.disable_permalinks:
|
||||
update_data["permalink"] = None
|
||||
elif content_markdown and content_markdown.frontmatter.permalink:
|
||||
update_data["permalink"] = await self.resolve_permalink(
|
||||
file_path, content_markdown, skip_conflict_check=True
|
||||
)
|
||||
|
||||
updated = await self.repository.update(entity.id, update_data)
|
||||
if not updated:
|
||||
raise ValueError(f"Failed to update entity in database: {entity.id}")
|
||||
return updated
|
||||
|
||||
async def reindex_entity(self, entity_id: int) -> None:
|
||||
"""Parse file content and rebuild observations/relations/search for an entity."""
|
||||
entity = await self.repository.find_by_id(entity_id)
|
||||
if not entity:
|
||||
raise EntityNotFoundError(f"Entity not found: {entity_id}")
|
||||
|
||||
# --- Full Parse ---
|
||||
file_path = Path(entity.file_path)
|
||||
content = await self.file_service.read_file_content(file_path)
|
||||
entity_markdown = await self.entity_parser.parse_markdown_content(
|
||||
file_path=file_path,
|
||||
content=content,
|
||||
)
|
||||
|
||||
# --- DB Reindex ---
|
||||
updated = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=False)
|
||||
checksum = await self.file_service.compute_checksum(file_path)
|
||||
updated = await self.repository.update(updated.id, {"checksum": checksum})
|
||||
if not updated:
|
||||
raise ValueError(f"Failed to update entity in database: {entity.id}")
|
||||
|
||||
# --- Search Reindex ---
|
||||
if self.search_service:
|
||||
await self.search_service.index_entity_data(updated, content=content)
|
||||
|
||||
async def delete_entity(self, permalink_or_id: str | int) -> bool:
|
||||
"""Delete entity and its file."""
|
||||
logger.debug(f"Deleting entity: {permalink_or_id}")
|
||||
@@ -465,6 +620,20 @@ class EntityService(BaseService[EntityModel]):
|
||||
db_entity,
|
||||
)
|
||||
|
||||
async def upsert_entity_from_markdown(
|
||||
self,
|
||||
file_path: Path,
|
||||
markdown: EntityMarkdown,
|
||||
*,
|
||||
is_new: bool,
|
||||
) -> EntityModel:
|
||||
"""Create/update entity and relations from parsed markdown."""
|
||||
if is_new:
|
||||
created = await self.create_entity_from_markdown(file_path, markdown)
|
||||
else:
|
||||
created = await self.update_entity_and_observations(file_path, markdown)
|
||||
return await self.update_entity_relations(created.file_path, markdown)
|
||||
|
||||
async def update_entity_relations(
|
||||
self,
|
||||
path: str,
|
||||
@@ -589,8 +758,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
)
|
||||
|
||||
# Update entity and its relationships
|
||||
entity = await self.update_entity_and_observations(file_path, entity_markdown)
|
||||
await self.update_entity_relations(file_path.as_posix(), entity_markdown)
|
||||
entity = await self.upsert_entity_from_markdown(file_path, entity_markdown, is_new=False)
|
||||
|
||||
# Set final checksum to match file
|
||||
entity = await self.repository.update(entity.id, {"checksum": checksum})
|
||||
@@ -927,7 +1095,9 @@ class EntityService(BaseService[EntityModel]):
|
||||
old_path = entity.file_path
|
||||
# Replace only the first occurrence of the source directory prefix
|
||||
if old_path.startswith(f"{source_directory}/"):
|
||||
new_path = old_path.replace(f"{source_directory}/", f"{destination_directory}/", 1)
|
||||
new_path = old_path.replace(
|
||||
f"{source_directory}/", f"{destination_directory}/", 1
|
||||
)
|
||||
else: # pragma: no cover
|
||||
# Entity is directly in the source directory (shouldn't happen with prefix match)
|
||||
new_path = f"{destination_directory}/{old_path}"
|
||||
@@ -1016,7 +1186,9 @@ class EntityService(BaseService[EntityModel]):
|
||||
logger.debug(f"Deleted entity: {file_path}")
|
||||
else: # pragma: no cover
|
||||
failed_deletes += 1
|
||||
errors.append(DirectoryDeleteError(path=file_path, error="Delete returned False"))
|
||||
errors.append(
|
||||
DirectoryDeleteError(path=file_path, error="Delete returned False")
|
||||
)
|
||||
logger.warning(f"Delete returned False for entity: {file_path}")
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import ast
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Set
|
||||
from typing import List, Optional, Set, Dict, Any
|
||||
|
||||
|
||||
from dateparser import parse
|
||||
@@ -95,6 +95,15 @@ class SearchService:
|
||||
else None
|
||||
)
|
||||
|
||||
# Merge structured metadata filters (explicit + convenience fields)
|
||||
metadata_filters: Optional[Dict[str, Any]] = None
|
||||
if query.metadata_filters or query.tags or query.status:
|
||||
metadata_filters = dict(query.metadata_filters or {})
|
||||
if query.tags:
|
||||
metadata_filters.setdefault("tags", query.tags)
|
||||
if query.status:
|
||||
metadata_filters.setdefault("status", query.status)
|
||||
|
||||
# search
|
||||
results = await self.repository.search(
|
||||
search_text=query.text,
|
||||
@@ -104,6 +113,7 @@ class SearchService:
|
||||
types=query.types,
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
@@ -685,19 +685,11 @@ class SyncService:
|
||||
entity_markdown.frontmatter.metadata["permalink"] = permalink
|
||||
await self.file_service.update_frontmatter(path, {"permalink": permalink})
|
||||
|
||||
# if the file is new, create an entity
|
||||
if new:
|
||||
# Create entity with final permalink
|
||||
logger.debug(f"Creating new entity from markdown, path={path}")
|
||||
await self.entity_service.create_entity_from_markdown(Path(path), entity_markdown)
|
||||
|
||||
# otherwise we need to update the entity and observations
|
||||
else:
|
||||
logger.debug(f"Updating entity from markdown, path={path}")
|
||||
await self.entity_service.update_entity_and_observations(Path(path), entity_markdown)
|
||||
|
||||
# Update relations and search index
|
||||
entity = await self.entity_service.update_entity_relations(path, entity_markdown)
|
||||
# Create/update entity and relations in one path
|
||||
logger.debug(f"{'Creating' if new else 'Updating'} entity from markdown, path={path}")
|
||||
entity = await self.entity_service.upsert_entity_from_markdown(
|
||||
Path(path), entity_markdown, is_new=new
|
||||
)
|
||||
|
||||
# After updating relations, we need to compute the checksum again
|
||||
# This is necessary for files with wikilinks to ensure consistent checksums
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Tests for knowledge graph API routes."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from basic_memory.deps import get_project_config, get_engine_factory, get_app_config
|
||||
from basic_memory.models import Project
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app(test_config, engine_factory, app_config) -> FastAPI:
|
||||
"""Create FastAPI test application."""
|
||||
from basic_memory.api.app import app
|
||||
|
||||
app.dependency_overrides[get_app_config] = lambda: app_config
|
||||
app.dependency_overrides[get_project_config] = lambda: test_config.project_config
|
||||
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create client using ASGI transport - same as CLI will use."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_url(test_project: Project) -> str:
|
||||
"""Create a URL prefix for the project routes.
|
||||
|
||||
This helps tests generate the correct URL for project-scoped routes.
|
||||
"""
|
||||
# Make sure this matches what's in tests/conftest.py for test_project creation
|
||||
# The permalink should be generated from "Test Project Context"
|
||||
return f"/{test_project.permalink}"
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Tests for API container composition root."""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.api.container import (
|
||||
ApiContainer,
|
||||
get_container,
|
||||
set_container,
|
||||
)
|
||||
from basic_memory.runtime import RuntimeMode
|
||||
|
||||
|
||||
class TestApiContainer:
|
||||
"""Tests for ApiContainer."""
|
||||
|
||||
def test_create_from_config(self, app_config):
|
||||
"""Container can be created from config manager."""
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.config == app_config
|
||||
assert container.mode == RuntimeMode.LOCAL
|
||||
|
||||
def test_should_sync_files_when_enabled_and_not_test(self, app_config):
|
||||
"""Sync should be enabled when config says so and not in test mode."""
|
||||
app_config.sync_changes = True
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.should_sync_files is True
|
||||
|
||||
def test_should_not_sync_files_when_disabled(self, app_config):
|
||||
"""Sync should be disabled when config says so."""
|
||||
app_config.sync_changes = False
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
assert container.should_sync_files is False
|
||||
|
||||
def test_should_not_sync_files_in_test_mode(self, app_config):
|
||||
"""Sync should be disabled in test mode regardless of config."""
|
||||
app_config.sync_changes = True
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.TEST)
|
||||
assert container.should_sync_files is False
|
||||
|
||||
|
||||
class TestContainerAccessors:
|
||||
"""Tests for container get/set functions."""
|
||||
|
||||
def test_get_container_raises_when_not_set(self, monkeypatch):
|
||||
"""get_container raises RuntimeError when container not initialized."""
|
||||
# Clear any existing container
|
||||
import basic_memory.api.container as container_module
|
||||
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="API container not initialized"):
|
||||
get_container()
|
||||
|
||||
def test_set_and_get_container(self, app_config, monkeypatch):
|
||||
"""set_container allows get_container to return the container."""
|
||||
import basic_memory.api.container as container_module
|
||||
|
||||
container = ApiContainer(config=app_config, mode=RuntimeMode.LOCAL)
|
||||
monkeypatch.setattr(container_module, "_container", None)
|
||||
|
||||
set_container(container)
|
||||
assert get_container() is container
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Tests for async_client configuration."""
|
||||
|
||||
from httpx import AsyncClient, ASGITransport, Timeout
|
||||
|
||||
from basic_memory.mcp.async_client import create_client
|
||||
|
||||
|
||||
def test_create_client_uses_asgi_when_no_remote_env(config_manager, monkeypatch):
|
||||
"""Test that create_client uses ASGI transport when cloud mode is disabled."""
|
||||
monkeypatch.delenv("BASIC_MEMORY_USE_REMOTE_API", raising=False)
|
||||
monkeypatch.delenv("BASIC_MEMORY_CLOUD_MODE", raising=False)
|
||||
|
||||
cfg = config_manager.load_config()
|
||||
cfg.cloud_mode = False
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
client = create_client()
|
||||
|
||||
assert isinstance(client, AsyncClient)
|
||||
assert isinstance(client._transport, ASGITransport)
|
||||
assert str(client.base_url) == "http://test"
|
||||
|
||||
|
||||
def test_create_client_uses_http_when_cloud_mode_env_set(config_manager, monkeypatch):
|
||||
"""Test that create_client uses HTTP transport when BASIC_MEMORY_CLOUD_MODE is set."""
|
||||
monkeypatch.setenv("BASIC_MEMORY_CLOUD_MODE", "True")
|
||||
|
||||
config = config_manager.load_config()
|
||||
client = create_client()
|
||||
|
||||
assert isinstance(client, AsyncClient)
|
||||
assert not isinstance(client._transport, ASGITransport)
|
||||
# Cloud mode uses cloud_host/proxy as base_url
|
||||
assert str(client.base_url) == f"{config.cloud_host}/proxy/"
|
||||
|
||||
|
||||
def test_create_client_configures_extended_timeouts(config_manager, monkeypatch):
|
||||
"""Test that create_client configures 30-second timeouts for long operations."""
|
||||
monkeypatch.delenv("BASIC_MEMORY_USE_REMOTE_API", raising=False)
|
||||
monkeypatch.delenv("BASIC_MEMORY_CLOUD_MODE", raising=False)
|
||||
|
||||
cfg = config_manager.load_config()
|
||||
cfg.cloud_mode = False
|
||||
config_manager.save_config(cfg)
|
||||
|
||||
client = create_client()
|
||||
|
||||
# Verify timeout configuration
|
||||
assert isinstance(client.timeout, Timeout)
|
||||
assert client.timeout.connect == 10.0 # 10 seconds for connection
|
||||
assert client.timeout.read == 30.0 # 30 seconds for reading
|
||||
assert client.timeout.write == 30.0 # 30 seconds for writing
|
||||
assert client.timeout.pool == 30.0 # 30 seconds for pool
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Tests for the continue_conversation template rendering."""
|
||||
|
||||
import datetime
|
||||
import pytest
|
||||
|
||||
from basic_memory.api.template_loader import TemplateLoader
|
||||
from basic_memory.schemas.memory import EntitySummary
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def template_loader():
|
||||
"""Return a TemplateLoader instance for testing."""
|
||||
return TemplateLoader()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entity_summary():
|
||||
"""Create a sample EntitySummary for testing."""
|
||||
return EntitySummary(
|
||||
entity_id=1,
|
||||
title="Test Entity",
|
||||
permalink="test/entity",
|
||||
type=SearchItemType.ENTITY,
|
||||
content="This is a test entity with some content.",
|
||||
file_path="/path/to/test/entity.md",
|
||||
created_at=datetime.datetime(2023, 1, 1, 12, 0),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context_with_results(entity_summary):
|
||||
"""Create a sample context with results for testing."""
|
||||
from basic_memory.schemas.memory import ObservationSummary, ContextResult
|
||||
|
||||
# Create an observation for the entity
|
||||
observation = ObservationSummary(
|
||||
observation_id=1,
|
||||
entity_id=1,
|
||||
title="Test Observation",
|
||||
permalink="test/entity/observations/1",
|
||||
category="test",
|
||||
content="This is a test observation.",
|
||||
file_path="/path/to/test/entity.md",
|
||||
created_at=datetime.datetime(2023, 1, 1, 12, 0),
|
||||
)
|
||||
|
||||
# Create a context result with primary_result, observations, and related_results
|
||||
context_item = ContextResult(
|
||||
primary_result=entity_summary,
|
||||
observations=[observation],
|
||||
related_results=[entity_summary],
|
||||
)
|
||||
|
||||
return {
|
||||
"topic": "Test Topic",
|
||||
"timeframe": "7d",
|
||||
"has_results": True,
|
||||
"hierarchical_results": [context_item],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context_without_results():
|
||||
"""Create a sample context without results for testing."""
|
||||
return {
|
||||
"topic": "Empty Topic",
|
||||
"timeframe": "1d",
|
||||
"has_results": False,
|
||||
"hierarchical_results": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_conversation_with_results(template_loader, context_with_results):
|
||||
"""Test rendering the continue_conversation template with results."""
|
||||
result = await template_loader.render("prompts/continue_conversation.hbs", context_with_results)
|
||||
|
||||
# Check that key elements are present
|
||||
assert "Continuing conversation on: Test Topic" in result
|
||||
assert "memory://test/entity" in result
|
||||
assert "Test Entity" in result
|
||||
assert "This is a test entity with some content." in result
|
||||
assert "Related Context" in result
|
||||
assert "read_note" in result
|
||||
assert "Next Steps" in result
|
||||
assert "Knowledge Capture Recommendation" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_conversation_without_results(template_loader, context_without_results):
|
||||
"""Test rendering the continue_conversation template without results."""
|
||||
result = await template_loader.render(
|
||||
"prompts/continue_conversation.hbs", context_without_results
|
||||
)
|
||||
|
||||
# Check that key elements are present
|
||||
assert "Continuing conversation on: Empty Topic" in result
|
||||
assert "The supplied query did not return any information" in result
|
||||
assert "Opportunity to Capture New Knowledge!" in result
|
||||
assert 'title="Empty Topic"' in result
|
||||
assert "Next Steps" in result
|
||||
assert "Knowledge Capture Recommendation" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_next_steps_section(template_loader, context_with_results):
|
||||
"""Test that the next steps section is rendered correctly."""
|
||||
result = await template_loader.render("prompts/continue_conversation.hbs", context_with_results)
|
||||
|
||||
assert "Next Steps" in result
|
||||
assert 'Explore more with: `search_notes("Test Topic")`' in result
|
||||
assert (
|
||||
f'See what\'s changed: `recent_activity(timeframe="{context_with_results["timeframe"]}")`'
|
||||
in result
|
||||
)
|
||||
assert "Record new learnings or decisions from this conversation" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_capture_recommendation(template_loader, context_with_results):
|
||||
"""Test that the knowledge capture recommendation is rendered."""
|
||||
result = await template_loader.render("prompts/continue_conversation.hbs", context_with_results)
|
||||
|
||||
assert "Knowledge Capture Recommendation" in result
|
||||
assert "actively look for opportunities to:" in result
|
||||
assert "Record key information, decisions, or insights" in result
|
||||
assert "Link new knowledge to existing topics" in result
|
||||
assert "Suggest capturing important context" in result
|
||||
assert "one of the most valuable aspects of Basic Memory" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeframe_default_value(template_loader, context_with_results):
|
||||
"""Test that the timeframe uses the default value when not provided."""
|
||||
# Remove the timeframe from the context
|
||||
context_without_timeframe = context_with_results.copy()
|
||||
context_without_timeframe["timeframe"] = None
|
||||
|
||||
result = await template_loader.render(
|
||||
"prompts/continue_conversation.hbs", context_without_timeframe
|
||||
)
|
||||
|
||||
# Check that the default value is used
|
||||
assert 'recent_activity(timeframe="7d")' in result
|
||||
@@ -1,212 +0,0 @@
|
||||
"""Tests for the directory router API endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_directory_tree_endpoint(test_graph, client, project_url):
|
||||
"""Test the get_directory_tree endpoint returns correctly structured data."""
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/directory/tree")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that the response is a valid directory tree
|
||||
assert "name" in data
|
||||
assert "directory_path" in data
|
||||
assert "children" in data
|
||||
assert "type" in data
|
||||
|
||||
# The root node should have children
|
||||
assert isinstance(data["children"], list)
|
||||
|
||||
# Root name should be the project name or similar
|
||||
assert data["name"]
|
||||
|
||||
# Root directory_path should be a string
|
||||
assert isinstance(data["directory_path"], str)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_directory_tree_structure(test_graph, client, project_url):
|
||||
"""Test the structure of the directory tree returned by the endpoint."""
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/directory/tree")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Function to recursively check each node in the tree
|
||||
def check_node_structure(node):
|
||||
assert "name" in node
|
||||
assert "directory_path" in node
|
||||
assert "children" in node
|
||||
assert "type" in node
|
||||
assert isinstance(node["children"], list)
|
||||
|
||||
# Check each child recursively
|
||||
for child in node["children"]:
|
||||
check_node_structure(child)
|
||||
|
||||
# Check the entire tree structure
|
||||
check_node_structure(data)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_endpoint_default(test_graph, client, project_url):
|
||||
"""Test the list_directory endpoint with default parameters."""
|
||||
# Call the endpoint with default parameters
|
||||
response = await client.get(f"{project_url}/directory/list")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should return a list
|
||||
assert isinstance(data, list)
|
||||
|
||||
# With test_graph, should return the "test" directory
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "test"
|
||||
assert data[0]["type"] == "directory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_endpoint_specific_path(test_graph, client, project_url):
|
||||
"""Test the list_directory endpoint with specific directory path."""
|
||||
# Call the endpoint with /test directory
|
||||
response = await client.get(f"{project_url}/directory/list?dir_name=/test")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should return list of files in test directory
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 5
|
||||
|
||||
# All should be files (no subdirectories in test_graph)
|
||||
for item in data:
|
||||
assert item["type"] == "file"
|
||||
assert item["name"].endswith(".md")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_endpoint_with_glob(test_graph, client, project_url):
|
||||
"""Test the list_directory endpoint with glob filtering."""
|
||||
# Call the endpoint with glob filter
|
||||
response = await client.get(
|
||||
f"{project_url}/directory/list?dir_name=/test&file_name_glob=*Connected*"
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should return only Connected Entity files
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 2
|
||||
|
||||
file_names = {item["name"] for item in data}
|
||||
assert file_names == {"Connected Entity 1.md", "Connected Entity 2.md"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_endpoint_with_depth(test_graph, client, project_url):
|
||||
"""Test the list_directory endpoint with depth control."""
|
||||
# Test depth=1 (default)
|
||||
response_depth_1 = await client.get(f"{project_url}/directory/list?dir_name=/&depth=1")
|
||||
assert response_depth_1.status_code == 200
|
||||
data_depth_1 = response_depth_1.json()
|
||||
assert len(data_depth_1) == 1 # Just the test directory
|
||||
|
||||
# Test depth=2 (should include files in test directory)
|
||||
response_depth_2 = await client.get(f"{project_url}/directory/list?dir_name=/&depth=2")
|
||||
assert response_depth_2.status_code == 200
|
||||
data_depth_2 = response_depth_2.json()
|
||||
assert len(data_depth_2) == 6 # test directory + 5 files
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_endpoint_nonexistent_path(test_graph, client, project_url):
|
||||
"""Test the list_directory endpoint with nonexistent directory."""
|
||||
# Call the endpoint with nonexistent directory
|
||||
response = await client.get(f"{project_url}/directory/list?dir_name=/nonexistent")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should return empty list
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_endpoint_validation_errors(client, project_url):
|
||||
"""Test the list_directory endpoint with invalid parameters."""
|
||||
# Test depth too low
|
||||
response = await client.get(f"{project_url}/directory/list?depth=0")
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
# Test depth too high
|
||||
response = await client.get(f"{project_url}/directory/list?depth=11")
|
||||
assert response.status_code == 422 # Validation error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_directory_structure_endpoint(test_graph, client, project_url):
|
||||
"""Test the get_directory_structure endpoint returns folders only."""
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/directory/structure")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that the response is a valid directory tree
|
||||
assert "name" in data
|
||||
assert "directory_path" in data
|
||||
assert "children" in data
|
||||
assert "type" in data
|
||||
assert data["type"] == "directory"
|
||||
|
||||
# Root should be present
|
||||
assert data["name"] == "Root"
|
||||
assert data["directory_path"] == "/"
|
||||
|
||||
# Should have the test directory
|
||||
assert len(data["children"]) == 1
|
||||
test_dir = data["children"][0]
|
||||
assert test_dir["name"] == "test"
|
||||
assert test_dir["type"] == "directory"
|
||||
assert test_dir["directory_path"] == "/test"
|
||||
|
||||
# Should NOT have any files (test_graph has files but no subdirectories)
|
||||
assert len(test_dir["children"]) == 0
|
||||
|
||||
# Verify no file metadata is present in directory nodes
|
||||
assert test_dir.get("entity_id") is None
|
||||
assert test_dir.get("content_type") is None
|
||||
assert test_dir.get("title") is None
|
||||
assert test_dir.get("permalink") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_directory_structure_empty(client, project_url):
|
||||
"""Test the get_directory_structure endpoint with empty database."""
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/directory/structure")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Should return root with no children
|
||||
assert data["name"] == "Root"
|
||||
assert data["directory_path"] == "/"
|
||||
assert data["type"] == "directory"
|
||||
assert len(data["children"]) == 0
|
||||
@@ -1,465 +0,0 @@
|
||||
"""Tests for importer API routes."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.schemas.importer import (
|
||||
ChatImportResult,
|
||||
EntityImportResult,
|
||||
ProjectImportResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chatgpt_json_content():
|
||||
"""Sample ChatGPT conversation data for testing."""
|
||||
return [
|
||||
{
|
||||
"title": "Test Conversation",
|
||||
"create_time": 1736616594.24054, # Example timestamp
|
||||
"update_time": 1736616603.164995,
|
||||
"mapping": {
|
||||
"root": {"id": "root", "message": None, "parent": None, "children": ["msg1"]},
|
||||
"msg1": {
|
||||
"id": "msg1",
|
||||
"message": {
|
||||
"id": "msg1",
|
||||
"author": {"role": "user", "name": None, "metadata": {}},
|
||||
"create_time": 1736616594.24054,
|
||||
"content": {
|
||||
"content_type": "text",
|
||||
"parts": ["Hello, this is a test message"],
|
||||
},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "root",
|
||||
"children": ["msg2"],
|
||||
},
|
||||
"msg2": {
|
||||
"id": "msg2",
|
||||
"message": {
|
||||
"id": "msg2",
|
||||
"author": {"role": "assistant", "name": None, "metadata": {}},
|
||||
"create_time": 1736616603.164995,
|
||||
"content": {"content_type": "text", "parts": ["This is a test response"]},
|
||||
"status": "finished_successfully",
|
||||
"metadata": {},
|
||||
},
|
||||
"parent": "msg1",
|
||||
"children": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_conversations_json_content():
|
||||
"""Sample Claude conversations data for testing."""
|
||||
return [
|
||||
{
|
||||
"uuid": "test-uuid",
|
||||
"name": "Test Conversation",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"chat_messages": [
|
||||
{
|
||||
"uuid": "msg-1",
|
||||
"text": "Hello, this is a test",
|
||||
"sender": "human",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"content": [{"type": "text", "text": "Hello, this is a test"}],
|
||||
},
|
||||
{
|
||||
"uuid": "msg-2",
|
||||
"text": "Response to test",
|
||||
"sender": "assistant",
|
||||
"created_at": "2025-01-05T20:55:40.123456+00:00",
|
||||
"content": [{"type": "text", "text": "Response to test"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_projects_json_content():
|
||||
"""Sample Claude projects data for testing."""
|
||||
return [
|
||||
{
|
||||
"uuid": "test-uuid",
|
||||
"name": "Test Project",
|
||||
"created_at": "2025-01-05T20:55:32.499880+00:00",
|
||||
"updated_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
"prompt_template": "# Test Prompt\n\nThis is a test prompt.",
|
||||
"docs": [
|
||||
{
|
||||
"uuid": "doc-uuid-1",
|
||||
"filename": "Test Document",
|
||||
"content": "# Test Document\n\nThis is test content.",
|
||||
"created_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
},
|
||||
{
|
||||
"uuid": "doc-uuid-2",
|
||||
"filename": "Another Document",
|
||||
"content": "# Another Document\n\nMore test content.",
|
||||
"created_at": "2025-01-05T20:56:39.477600+00:00",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_json_content():
|
||||
"""Sample memory.json data for testing."""
|
||||
return [
|
||||
{
|
||||
"type": "entity",
|
||||
"name": "test_entity",
|
||||
"entityType": "test",
|
||||
"observations": ["Test observation 1", "Test observation 2"],
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"from": "test_entity",
|
||||
"to": "related_entity",
|
||||
"relationType": "test_relation",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def create_test_upload_file(tmp_path, content):
|
||||
"""Create a test file for upload."""
|
||||
file_path = tmp_path / "test_import.json"
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(content, f)
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_chatgpt(
|
||||
project_config, client: AsyncClient, tmp_path, chatgpt_json_content, file_service, project_url
|
||||
):
|
||||
"""Test importing ChatGPT conversations."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, chatgpt_json_content)
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = ChatImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.conversations == 1
|
||||
assert result.messages == 2
|
||||
|
||||
# Verify files were created
|
||||
conv_path = Path("test_chatgpt") / "20250111-Test_Conversation.md"
|
||||
assert await file_service.exists(conv_path)
|
||||
|
||||
content, _ = await file_service.read_file(conv_path)
|
||||
assert "# Test Conversation" in content
|
||||
assert "Hello, this is a test message" in content
|
||||
assert "This is a test response" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing invalid ChatGPT file."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_conversations(
|
||||
client: AsyncClient, tmp_path, claude_conversations_json_content, file_service, project_url
|
||||
):
|
||||
"""Test importing Claude conversations."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, claude_conversations_json_content)
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"directory": "test_claude_conversations"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
f"{project_url}/import/claude/conversations", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = ChatImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.conversations == 1
|
||||
assert result.messages == 2
|
||||
|
||||
# Verify files were created
|
||||
conv_path = Path("test_claude_conversations") / "20250105-Test_Conversation.md"
|
||||
assert await file_service.exists(conv_path)
|
||||
|
||||
content, _ = await file_service.read_file(conv_path)
|
||||
assert "# Test Conversation" in content
|
||||
assert "Hello, this is a test" in content
|
||||
assert "Response to test" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_conversations_invalid_file(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing invalid Claude conversations file."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"directory": "test_claude_conversations"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
f"{project_url}/import/claude/conversations", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_projects(
|
||||
client: AsyncClient, tmp_path, claude_projects_json_content, file_service, project_url
|
||||
):
|
||||
"""Test importing Claude projects."""
|
||||
# Create a test file
|
||||
file_path = await create_test_upload_file(tmp_path, claude_projects_json_content)
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("projects.json", f, "application/json")}
|
||||
data = {"directory": "test_claude_projects"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
f"{project_url}/import/claude/projects", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = ProjectImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.documents == 2
|
||||
assert result.prompts == 1
|
||||
|
||||
# Verify files were created
|
||||
project_dir = Path("test_claude_projects") / "Test_Project"
|
||||
assert await file_service.exists(project_dir / "prompt-template.md")
|
||||
assert await file_service.exists(project_dir / "docs" / "Test_Document.md")
|
||||
assert await file_service.exists(project_dir / "docs" / "Another_Document.md")
|
||||
|
||||
# Check content
|
||||
prompt_content, _ = await file_service.read_file(project_dir / "prompt-template.md")
|
||||
assert "# Test Prompt" in prompt_content
|
||||
|
||||
doc_content, _ = await file_service.read_file(project_dir / "docs" / "Test_Document.md")
|
||||
assert "# Test Document" in doc_content
|
||||
assert "This is test content" in doc_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_claude_projects_invalid_file(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing invalid Claude projects file."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"directory": "test_claude_projects"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
f"{project_url}/import/claude/projects", files=files, data=data
|
||||
)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_memory_json(
|
||||
client: AsyncClient, tmp_path, memory_json_content, file_service, project_url
|
||||
):
|
||||
"""Test importing memory.json file."""
|
||||
# Create a test file
|
||||
json_file = tmp_path / "memory.json"
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
for entity in memory_json_content:
|
||||
f.write(json.dumps(entity) + "\n")
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(json_file, "rb") as f:
|
||||
files = {"file": ("memory.json", f, "application/json")}
|
||||
data = {"directory": "test_memory_json"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{project_url}/import/memory-json", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = EntityImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.entities == 1
|
||||
assert result.relations == 1
|
||||
|
||||
# Verify files were created
|
||||
entity_path = Path("test_memory_json") / "test" / "test_entity.md"
|
||||
assert await file_service.exists(entity_path)
|
||||
|
||||
# Check content
|
||||
content, _ = await file_service.read_file(entity_path)
|
||||
assert "Test observation 1" in content
|
||||
assert "Test observation 2" in content
|
||||
assert "test_relation [[related_entity]]" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_memory_json_without_folder(
|
||||
client: AsyncClient, tmp_path, memory_json_content, file_service, project_url
|
||||
):
|
||||
"""Test importing memory.json file without specifying a destination folder."""
|
||||
# Create a test file
|
||||
json_file = tmp_path / "memory.json"
|
||||
with open(json_file, "w", encoding="utf-8") as f:
|
||||
for entity in memory_json_content:
|
||||
f.write(json.dumps(entity) + "\n")
|
||||
|
||||
# Create a multipart form with the file
|
||||
with open(json_file, "rb") as f:
|
||||
files = {"file": ("memory.json", f, "application/json")}
|
||||
|
||||
# Send request without destination_folder
|
||||
response = await client.post(f"{project_url}/import/memory-json", files=files)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
result = EntityImportResult.model_validate(response.json())
|
||||
assert result.success is True
|
||||
assert result.entities == 1
|
||||
assert result.relations == 1
|
||||
|
||||
# Verify files were created in the root directory
|
||||
entity_path = Path("conversations") / "test" / "test_entity.md"
|
||||
assert await file_service.exists(entity_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing invalid memory.json file."""
|
||||
# Create invalid file
|
||||
file_path = tmp_path / "invalid.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("This is not JSON")
|
||||
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"destination_folder": "test_memory_json"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(f"{project_url}/import/memory-json", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_missing_file(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing with missing file."""
|
||||
# Send a request without a file
|
||||
response = await client.post(f"{project_url}/import/chatgpt", data={"directory": "test_folder"})
|
||||
|
||||
# Check that the request was rejected
|
||||
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_empty_file(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing an empty file."""
|
||||
# Create an empty file
|
||||
file_path = tmp_path / "empty.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write("")
|
||||
|
||||
# Create multipart form with empty file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("empty.json", f, "application/json")}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_malformed_json(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing malformed JSON for all import endpoints."""
|
||||
# Create malformed JSON file
|
||||
file_path = tmp_path / "malformed.json"
|
||||
with open(file_path, "w") as f:
|
||||
f.write('{"incomplete": "json"') # Missing closing brace
|
||||
|
||||
# Test all import endpoints
|
||||
endpoints = [
|
||||
(f"{project_url}/import/chatgpt", {"directory": "test"}),
|
||||
(f"{project_url}/import/claude/conversations", {"directory": "test"}),
|
||||
(f"{project_url}/import/claude/projects", {"base_folder": "test"}),
|
||||
(f"{project_url}/import/memory-json", {"destination_folder": "test"}),
|
||||
]
|
||||
|
||||
for endpoint, data in endpoints:
|
||||
# Create multipart form with malformed JSON
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("malformed.json", f, "application/json")}
|
||||
|
||||
# Send request
|
||||
response = await client.post(endpoint, files=files, data=data)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 500
|
||||
assert "Import failed" in response.json()["detail"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
"""Tests for management router API endpoints (minimal mocking).
|
||||
|
||||
These endpoints are mostly simple state checks and wiring; we use stub objects
|
||||
and pytest monkeypatch instead of standard-library mocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from basic_memory.api.routers.management_router import (
|
||||
WatchStatusResponse,
|
||||
get_watch_status,
|
||||
start_watch_service,
|
||||
stop_watch_service,
|
||||
)
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, app: FastAPI):
|
||||
self.app = app
|
||||
|
||||
|
||||
class _Task:
|
||||
def __init__(self, *, done: bool):
|
||||
self._done = done
|
||||
self.cancel_called = False
|
||||
|
||||
def done(self) -> bool:
|
||||
return self._done
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancel_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_with_state() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.state.watch_task = None
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_watch_status_not_running(app_with_state: FastAPI):
|
||||
app_with_state.state.watch_task = None
|
||||
resp = await get_watch_status(_Request(app_with_state))
|
||||
assert isinstance(resp, WatchStatusResponse)
|
||||
assert resp.running is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_watch_status_running(app_with_state: FastAPI):
|
||||
app_with_state.state.watch_task = _Task(done=False)
|
||||
resp = await get_watch_status(_Request(app_with_state))
|
||||
assert resp.running is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_watch_service_when_not_running(monkeypatch, app_with_state: FastAPI):
|
||||
app_with_state.state.watch_task = None
|
||||
|
||||
created = {"watch_service": None, "task": None}
|
||||
|
||||
class _StubWatchService:
|
||||
def __init__(self, *, app_config, project_repository):
|
||||
self.app_config = app_config
|
||||
self.project_repository = project_repository
|
||||
created["watch_service"] = self
|
||||
|
||||
def _create_background_sync_task(sync_service, watch_service):
|
||||
created["task"] = _Task(done=False)
|
||||
return created["task"]
|
||||
|
||||
# start_watch_service imports these inside the function, so patch at the source modules.
|
||||
monkeypatch.setattr("basic_memory.sync.WatchService", _StubWatchService)
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.sync.background_sync.create_background_sync_task",
|
||||
_create_background_sync_task,
|
||||
)
|
||||
|
||||
project_repository = object()
|
||||
sync_service = object()
|
||||
|
||||
resp = await start_watch_service(_Request(app_with_state), project_repository, sync_service)
|
||||
assert resp.running is True
|
||||
assert app_with_state.state.watch_task is created["task"]
|
||||
assert created["watch_service"] is not None
|
||||
assert created["watch_service"].project_repository is project_repository
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_watch_service_already_running(monkeypatch, app_with_state: FastAPI):
|
||||
existing = _Task(done=False)
|
||||
app_with_state.state.watch_task = existing
|
||||
|
||||
def _should_not_be_called(*_args, **_kwargs):
|
||||
raise AssertionError("create_background_sync_task should not be called if already running")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"basic_memory.sync.background_sync.create_background_sync_task",
|
||||
_should_not_be_called,
|
||||
)
|
||||
|
||||
resp = await start_watch_service(_Request(app_with_state), object(), object())
|
||||
assert resp.running is True
|
||||
assert app_with_state.state.watch_task is existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_watch_service_not_running(app_with_state: FastAPI):
|
||||
app_with_state.state.watch_task = None
|
||||
resp = await stop_watch_service(_Request(app_with_state))
|
||||
assert resp.running is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_watch_service_already_done(app_with_state: FastAPI):
|
||||
app_with_state.state.watch_task = _Task(done=True)
|
||||
resp = await stop_watch_service(_Request(app_with_state))
|
||||
assert resp.running is False
|
||||
@@ -1,146 +0,0 @@
|
||||
"""Tests for memory router endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.schemas.memory import GraphContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context(client, test_graph, project_url):
|
||||
"""Test getting context from memory URL."""
|
||||
response = await client.get(f"{project_url}/memory/test/root")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.results) == 1
|
||||
assert context.results[0].primary_result.permalink == "test/root"
|
||||
assert len(context.results[0].related_results) > 0
|
||||
|
||||
# Verify metadata
|
||||
assert context.metadata.uri == "test/root"
|
||||
assert context.metadata.depth == 1 # default depth
|
||||
assert isinstance(context.metadata.generated_at, datetime)
|
||||
assert context.metadata.primary_count + context.metadata.related_count > 0
|
||||
assert context.metadata.total_results is not None # Backwards compatibility field
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pagination(client, test_graph, project_url):
|
||||
"""Test getting context from memory URL."""
|
||||
response = await client.get(f"{project_url}/memory/test/root?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.results) == 1
|
||||
assert context.results[0].primary_result.permalink == "test/root"
|
||||
assert len(context.results[0].related_results) > 0
|
||||
|
||||
# Verify metadata
|
||||
assert context.metadata.uri == "test/root"
|
||||
assert context.metadata.depth == 1 # default depth
|
||||
assert isinstance(context.metadata.generated_at, datetime)
|
||||
assert context.metadata.primary_count > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_pattern(client, test_graph, project_url):
|
||||
"""Test getting context with pattern matching."""
|
||||
response = await client.get(f"{project_url}/memory/test/*")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.results) > 1 # Should match multiple test/* paths
|
||||
assert all("test/" in item.primary_result.permalink for item in context.results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_depth(client, test_graph, project_url):
|
||||
"""Test depth parameter affects relation traversal."""
|
||||
# With depth=1, should only get immediate connections
|
||||
response = await client.get(f"{project_url}/memory/test/root?depth=1&max_results=20")
|
||||
assert response.status_code == 200
|
||||
context1 = GraphContext(**response.json())
|
||||
|
||||
# With depth=2, should get deeper connections
|
||||
response = await client.get(f"{project_url}/memory/test/root?depth=3&max_results=20")
|
||||
assert response.status_code == 200
|
||||
context2 = GraphContext(**response.json())
|
||||
|
||||
# Calculate total related items in all result items
|
||||
total_related1 = sum(len(item.related_results) for item in context1.results)
|
||||
total_related2 = sum(len(item.related_results) for item in context2.results)
|
||||
|
||||
assert total_related2 > total_related1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_memory_context_timeframe(client, test_graph, project_url):
|
||||
"""Test timeframe parameter filters by date."""
|
||||
# Recent timeframe
|
||||
response = await client.get(f"{project_url}/memory/test/root?timeframe=1d")
|
||||
assert response.status_code == 200
|
||||
recent = GraphContext(**response.json())
|
||||
|
||||
# Longer timeframe
|
||||
response = await client.get(f"{project_url}/memory/test/root?timeframe=30d")
|
||||
assert response.status_code == 200
|
||||
older = GraphContext(**response.json())
|
||||
|
||||
# Calculate total related items
|
||||
total_recent_related = (
|
||||
sum(len(item.related_results) for item in recent.results) if recent.results else 0
|
||||
)
|
||||
total_older_related = (
|
||||
sum(len(item.related_results) for item in older.results) if older.results else 0
|
||||
)
|
||||
|
||||
assert total_older_related >= total_recent_related
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_found(client, project_url):
|
||||
"""Test handling of non-existent paths."""
|
||||
response = await client.get(f"{project_url}/memory/test/does-not-exist")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity(client, test_graph, project_url):
|
||||
"""Test handling of recent activity."""
|
||||
response = await client.get(f"{project_url}/memory/recent")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.results) > 0
|
||||
assert context.metadata.primary_count > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_pagination(client, test_graph, project_url):
|
||||
"""Test pagination for recent activity."""
|
||||
response = await client.get(f"{project_url}/memory/recent?page=1&page_size=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.results) == 1
|
||||
assert context.page == 1
|
||||
assert context.page_size == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_by_type(client, test_graph, project_url):
|
||||
"""Test filtering recent activity by type."""
|
||||
response = await client.get(f"{project_url}/memory/recent?type=relation&type=observation")
|
||||
assert response.status_code == 200
|
||||
|
||||
context = GraphContext(**response.json())
|
||||
assert len(context.results) > 0
|
||||
|
||||
# Check for relation and observation types in primary results
|
||||
primary_types = [item.primary_result.type for item in context.results]
|
||||
assert "relation" in primary_types or "observation" in primary_types
|
||||
@@ -1,843 +0,0 @@
|
||||
"""Tests for the project router API endpoints."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.schemas.project_info import ProjectItem
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_item(test_graph, client, project_config, test_project, project_url):
|
||||
"""Test the project item endpoint returns correctly structured data."""
|
||||
# Set up some test data in the database
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/project/item")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
project_info = ProjectItem.model_validate(response.json())
|
||||
assert project_info.name == test_project.name
|
||||
assert project_info.path == test_project.path
|
||||
assert project_info.is_default == test_project.is_default
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_item_not_found(
|
||||
test_graph, client, project_config, test_project, project_url
|
||||
):
|
||||
"""Test the project item endpoint returns correctly structured data."""
|
||||
# Set up some test data in the database
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.get("/not-found/project/item")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_default_project(test_graph, client, project_config, test_project, project_url):
|
||||
"""Test the default project item endpoint returns the default project."""
|
||||
# Set up some test data in the database
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.get("/projects/default")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
project_info = ProjectItem.model_validate(response.json())
|
||||
assert project_info.name == test_project.name
|
||||
assert project_info.path == test_project.path
|
||||
assert project_info.is_default == test_project.is_default
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_info_endpoint(test_graph, client, project_config, project_url):
|
||||
"""Test the project-info endpoint returns correctly structured data."""
|
||||
# Set up some test data in the database
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/project/info")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check top-level keys
|
||||
assert "project_name" in data
|
||||
assert "project_path" in data
|
||||
assert "available_projects" in data
|
||||
assert "default_project" in data
|
||||
assert "statistics" in data
|
||||
assert "activity" in data
|
||||
assert "system" in data
|
||||
|
||||
# Check statistics
|
||||
stats = data["statistics"]
|
||||
assert "total_entities" in stats
|
||||
assert stats["total_entities"] >= 0
|
||||
assert "total_observations" in stats
|
||||
assert stats["total_observations"] >= 0
|
||||
assert "total_relations" in stats
|
||||
assert stats["total_relations"] >= 0
|
||||
|
||||
# Check activity
|
||||
activity = data["activity"]
|
||||
assert "recently_created" in activity
|
||||
assert "recently_updated" in activity
|
||||
assert "monthly_growth" in activity
|
||||
|
||||
# Check system
|
||||
system = data["system"]
|
||||
assert "version" in system
|
||||
assert "database_path" in system
|
||||
assert "database_size" in system
|
||||
assert "timestamp" in system
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_info_content(test_graph, client, project_config, project_url):
|
||||
"""Test that project-info contains actual data from the test database."""
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/project/info")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that test_graph content is reflected in statistics
|
||||
stats = data["statistics"]
|
||||
|
||||
# Our test graph should have at least a few entities
|
||||
assert stats["total_entities"] > 0
|
||||
|
||||
# It should also have some observations
|
||||
assert stats["total_observations"] > 0
|
||||
|
||||
# And relations
|
||||
assert stats["total_relations"] > 0
|
||||
|
||||
# Check that entity types include 'test'
|
||||
assert "test" in stats["entity_types"] or "entity" in stats["entity_types"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_projects_endpoint(test_config, test_graph, client, project_config, project_url):
|
||||
"""Test the list projects endpoint returns correctly structured data."""
|
||||
# Call the endpoint
|
||||
response = await client.get("/projects/projects")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that the response contains expected fields
|
||||
assert "projects" in data
|
||||
assert "default_project" in data
|
||||
|
||||
# Check that projects is a list
|
||||
assert isinstance(data["projects"], list)
|
||||
|
||||
# There should be at least one project (the test project)
|
||||
assert len(data["projects"]) > 0
|
||||
|
||||
# Verify project item structure
|
||||
if data["projects"]:
|
||||
project = data["projects"][0]
|
||||
assert "name" in project
|
||||
assert "path" in project
|
||||
assert "is_default" in project
|
||||
|
||||
# Default project should be marked
|
||||
default_project = next((p for p in data["projects"] if p["is_default"]), None)
|
||||
assert default_project is not None
|
||||
assert default_project["name"] == data["default_project"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_endpoint(test_config, client, project_service):
|
||||
"""Test the remove project endpoint."""
|
||||
# First create a test project to remove
|
||||
test_project_name = "test-remove-project"
|
||||
await project_service.add_project(test_project_name, "/tmp/test-remove-project")
|
||||
|
||||
# Verify it exists
|
||||
project = await project_service.get_project(test_project_name)
|
||||
assert project is not None
|
||||
|
||||
# Remove the project
|
||||
response = await client.delete(f"/projects/{test_project_name}")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert "old_project" in data
|
||||
assert data["old_project"]["name"] == test_project_name
|
||||
|
||||
# Verify project is actually removed
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_default_project_endpoint(test_config, client, project_service):
|
||||
"""Test the set default project endpoint."""
|
||||
# Create a test project to set as default
|
||||
test_project_name = "test-default-project"
|
||||
await project_service.add_project(test_project_name, "/tmp/test-default-project")
|
||||
|
||||
# Set it as default
|
||||
response = await client.put(f"/projects/{test_project_name}/default")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert "new_project" in data
|
||||
assert data["new_project"]["name"] == test_project_name
|
||||
|
||||
# Verify it's actually set as default
|
||||
assert project_service.default_project == test_project_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_path_endpoint(test_config, client, project_service, project_url):
|
||||
"""Test the update project endpoint for changing project path."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-project"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
old_path = test_root / "old-location"
|
||||
new_path = test_root / "new-location"
|
||||
|
||||
await project_service.add_project(test_project_name, str(old_path))
|
||||
|
||||
try:
|
||||
# Verify initial state
|
||||
project = await project_service.get_project(test_project_name)
|
||||
assert project is not None
|
||||
assert Path(project.path) == old_path
|
||||
|
||||
# Update the project path
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"path": str(new_path)}
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert "old_project" in data
|
||||
assert "new_project" in data
|
||||
|
||||
# Check old project data
|
||||
assert data["old_project"]["name"] == test_project_name
|
||||
assert Path(data["old_project"]["path"]) == old_path
|
||||
|
||||
# Check new project data
|
||||
assert data["new_project"]["name"] == test_project_name
|
||||
assert Path(data["new_project"]["path"]) == new_path
|
||||
|
||||
# Verify project was actually updated in database
|
||||
updated_project = await project_service.get_project(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert Path(updated_project.path) == new_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_is_active_endpoint(test_config, client, project_service, project_url):
|
||||
"""Test the update project endpoint for changing is_active status."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-active-project"
|
||||
test_path = "/tmp/test-update-active"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
try:
|
||||
# Update the project is_active status
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"is_active": False}
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert f"Project '{test_project_name}' updated successfully" == data["message"]
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_both_params_endpoint(
|
||||
test_config, client, project_service, project_url
|
||||
):
|
||||
"""Test the update project endpoint with both path and is_active parameters."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-both-project"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_root = Path(temp_dir)
|
||||
old_path = (test_root / "old-location").as_posix()
|
||||
new_path = (test_root / "new-location").as_posix()
|
||||
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
|
||||
try:
|
||||
# Update both path and is_active (path should take precedence)
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={"path": new_path, "is_active": False},
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that path update was performed (takes precedence)
|
||||
assert data["new_project"]["path"] == new_path
|
||||
|
||||
# Verify project was actually updated in database
|
||||
updated_project = await project_service.get_project(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_nonexistent_endpoint(client, project_url, tmp_path):
|
||||
"""Test the update project endpoint with a nonexistent project."""
|
||||
# Try to update a project that doesn't exist
|
||||
# Use tmp_path for cross-platform absolute path compatibility
|
||||
new_path = str(tmp_path / "new-path")
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/nonexistent-project", json={"path": new_path}
|
||||
)
|
||||
|
||||
# Should return 400 error
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "not found in configuration" in data["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_relative_path_error_endpoint(
|
||||
test_config, client, project_service, project_url
|
||||
):
|
||||
"""Test the update project endpoint with relative path (should fail)."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-relative-project"
|
||||
test_path = "/tmp/test-update-relative"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
try:
|
||||
# Try to update with relative path
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"path": "./relative-path"}
|
||||
)
|
||||
|
||||
# Should return 400 error
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "Path must be absolute" in data["detail"]
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_no_params_endpoint(test_config, client, project_service, project_url):
|
||||
"""Test the update project endpoint with no parameters (should fail)."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-no-params-project"
|
||||
test_path = "/tmp/test-update-no-params"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
proj_info = await project_service.get_project(test_project_name)
|
||||
assert proj_info.name == test_project_name
|
||||
# On Windows the path is prepended with a drive letter
|
||||
assert test_path in proj_info.path
|
||||
|
||||
try:
|
||||
# Try to update with no parameters
|
||||
response = await client.patch(f"{project_url}/project/{test_project_name}", json={})
|
||||
|
||||
# Should return 200 (no-op)
|
||||
assert response.status_code == 200
|
||||
proj_info = await project_service.get_project(test_project_name)
|
||||
assert proj_info.name == test_project_name
|
||||
# On Windows the path is prepended with a drive letter
|
||||
assert test_path in proj_info.path
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_empty_path_endpoint(
|
||||
test_config, client, project_service, project_url
|
||||
):
|
||||
"""Test the update project endpoint with empty path parameter."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-empty-path-project"
|
||||
test_path = "/tmp/test-update-empty-path"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
try:
|
||||
# Try to update with empty/null path - should be treated as no path update
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}", json={"path": None, "is_active": True}
|
||||
)
|
||||
|
||||
# Should succeed and perform is_active update
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_project_endpoint(test_graph, client, project_url):
|
||||
"""Test the project sync endpoint initiates background sync."""
|
||||
# Call the sync endpoint
|
||||
response = await client.post(f"{project_url}/project/sync")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "status" in data
|
||||
assert "message" in data
|
||||
assert data["status"] == "sync_started"
|
||||
assert "Filesystem sync initiated" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_project_endpoint_with_force_full(test_graph, client, project_url):
|
||||
"""Test the project sync endpoint with force_full parameter."""
|
||||
# Call the sync endpoint with force_full=true
|
||||
response = await client.post(f"{project_url}/project/sync?force_full=true")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "status" in data
|
||||
assert "message" in data
|
||||
assert data["status"] == "sync_started"
|
||||
assert "Filesystem sync initiated" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_project_endpoint_with_force_full_false(test_graph, client, project_url):
|
||||
"""Test the project sync endpoint with force_full=false."""
|
||||
# Call the sync endpoint with force_full=false
|
||||
response = await client.post(f"{project_url}/project/sync?force_full=false")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check response structure
|
||||
assert "status" in data
|
||||
assert "message" in data
|
||||
assert data["status"] == "sync_started"
|
||||
assert "Filesystem sync initiated" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_project_endpoint_not_found(client):
|
||||
"""Test the project sync endpoint with nonexistent project."""
|
||||
# Call the sync endpoint for a project that doesn't exist
|
||||
response = await client.post("/nonexistent-project/project/sync")
|
||||
|
||||
# Should return 404
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_project_endpoint_foreground(test_graph, client, project_url):
|
||||
"""Test the project sync endpoint with run_in_background=false returns sync report."""
|
||||
# Call the sync endpoint with run_in_background=false
|
||||
response = await client.post(f"{project_url}/project/sync?run_in_background=false")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that we get a sync report instead of status message
|
||||
assert "new" in data
|
||||
assert "modified" in data
|
||||
assert "deleted" in data
|
||||
assert "moves" in data
|
||||
assert "checksums" in data
|
||||
assert "skipped_files" in data
|
||||
assert "total" in data
|
||||
|
||||
# Verify these are the right types
|
||||
assert isinstance(data["new"], list)
|
||||
assert isinstance(data["modified"], list)
|
||||
assert isinstance(data["deleted"], list)
|
||||
assert isinstance(data["moves"], dict)
|
||||
assert isinstance(data["checksums"], dict)
|
||||
assert isinstance(data["skipped_files"], list)
|
||||
assert isinstance(data["total"], int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_project_endpoint_foreground_with_force_full(test_graph, client, project_url):
|
||||
"""Test the project sync endpoint with run_in_background=false and force_full=true."""
|
||||
# Call the sync endpoint with both parameters
|
||||
response = await client.post(
|
||||
f"{project_url}/project/sync?run_in_background=false&force_full=true"
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that we get a sync report with all expected fields
|
||||
assert "new" in data
|
||||
assert "modified" in data
|
||||
assert "deleted" in data
|
||||
assert "moves" in data
|
||||
assert "checksums" in data
|
||||
assert "skipped_files" in data
|
||||
assert "total" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_project_endpoint_foreground_with_changes(
|
||||
test_graph, client, project_config, project_url, tmpdir
|
||||
):
|
||||
"""Test foreground sync detects actual file changes."""
|
||||
# Create a new file in the project directory
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
test_file = Path(project_config.home) / "new_test_file.md"
|
||||
test_file.write_text("# New Test File\n\nThis is a test file for sync detection.")
|
||||
|
||||
try:
|
||||
# Call the sync endpoint with run_in_background=false
|
||||
response = await client.post(f"{project_url}/project/sync?run_in_background=false")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# The sync report should show changes (the new file we created)
|
||||
assert data["total"] >= 0 # Should have at least detected changes
|
||||
assert "new" in data
|
||||
assert "modified" in data
|
||||
assert "deleted" in data
|
||||
|
||||
# At least one of these should have changes
|
||||
has_changes = len(data["new"]) > 0 or len(data["modified"]) > 0 or len(data["deleted"]) > 0
|
||||
assert has_changes or data["total"] >= 0 # Either changes detected or empty sync is valid
|
||||
|
||||
finally:
|
||||
# Clean up the test file
|
||||
if test_file.exists():
|
||||
os.remove(test_file)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_default_project_fails(test_config, client, project_service):
|
||||
"""Test that removing the default project returns an error."""
|
||||
# Get the current default project
|
||||
default_project_name = project_service.default_project
|
||||
|
||||
# Try to remove the default project
|
||||
response = await client.delete(f"/projects/{default_project_name}")
|
||||
|
||||
# Should return 400 with helpful error message
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "Cannot delete default project" in data["detail"]
|
||||
assert default_project_name in data["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_default_project_with_alternatives(test_config, client, project_service):
|
||||
"""Test that error message includes alternative projects when trying to delete default."""
|
||||
# Get the current default project
|
||||
default_project_name = project_service.default_project
|
||||
|
||||
# Create another project so there are alternatives
|
||||
test_project_name = "test-alternative-project"
|
||||
await project_service.add_project(test_project_name, "/tmp/test-alternative")
|
||||
|
||||
try:
|
||||
# Try to remove the default project
|
||||
response = await client.delete(f"/projects/{default_project_name}")
|
||||
|
||||
# Should return 400 with helpful error message including alternatives
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "Cannot delete default project" in data["detail"]
|
||||
assert "Set another project as default first" in data["detail"]
|
||||
assert test_project_name in data["detail"]
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_non_default_project_succeeds(test_config, client, project_service):
|
||||
"""Test that removing a non-default project succeeds."""
|
||||
# Create a test project to remove
|
||||
test_project_name = "test-remove-non-default"
|
||||
await project_service.add_project(test_project_name, "/tmp/test-remove-non-default")
|
||||
|
||||
# Verify it's not the default
|
||||
assert project_service.default_project != test_project_name
|
||||
|
||||
# Remove the project
|
||||
response = await client.delete(f"/projects/{test_project_name}")
|
||||
|
||||
# Should succeed
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify project is removed
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_nonexistent_project_as_default_fails(test_config, client, project_service):
|
||||
"""Test that setting a non-existent project as default returns 404."""
|
||||
# Try to set a project that doesn't exist as default
|
||||
response = await client.put("/projects/nonexistent-project/default")
|
||||
|
||||
# Should return 404
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "does not exist" in data["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_idempotent_same_path(test_config, client, project_service):
|
||||
"""Test that creating a project with same name and same path is idempotent."""
|
||||
# Create a project with platform-independent path
|
||||
test_project_name = "test-idempotent"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_project_path = (Path(temp_dir) / "test-idempotent").as_posix()
|
||||
|
||||
response1 = await client.post(
|
||||
"/projects/projects",
|
||||
json={"name": test_project_name, "path": test_project_path, "set_default": False},
|
||||
)
|
||||
|
||||
# Should succeed with 201 Created
|
||||
assert response1.status_code == 201
|
||||
data1 = response1.json()
|
||||
assert data1["status"] == "success"
|
||||
assert data1["new_project"]["name"] == test_project_name
|
||||
|
||||
# Try to create the same project again with same name and path
|
||||
response2 = await client.post(
|
||||
"/projects/projects",
|
||||
json={"name": test_project_name, "path": test_project_path, "set_default": False},
|
||||
)
|
||||
|
||||
# Should also succeed (idempotent)
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
assert data2["status"] == "success"
|
||||
assert "already exists" in data2["message"]
|
||||
assert data2["new_project"]["name"] == test_project_name
|
||||
# Normalize paths for cross-platform comparison
|
||||
assert Path(data2["new_project"]["path"]).resolve() == Path(test_project_path).resolve()
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_fails_different_path(test_config, client, project_service):
|
||||
"""Test that creating a project with same name but different path fails."""
|
||||
# Create a project
|
||||
test_project_name = "test-path-conflict"
|
||||
test_project_path1 = "/tmp/test-path-conflict-1"
|
||||
|
||||
response1 = await client.post(
|
||||
"/projects/projects",
|
||||
json={"name": test_project_name, "path": test_project_path1, "set_default": False},
|
||||
)
|
||||
|
||||
# Should succeed with 201 Created
|
||||
assert response1.status_code == 201
|
||||
|
||||
# Try to create the same project with different path
|
||||
test_project_path2 = "/tmp/test-path-conflict-2"
|
||||
response2 = await client.post(
|
||||
"/projects/projects",
|
||||
json={"name": test_project_name, "path": test_project_path2, "set_default": False},
|
||||
)
|
||||
|
||||
# Should fail with 400
|
||||
assert response2.status_code == 400
|
||||
data2 = response2.json()
|
||||
assert "detail" in data2
|
||||
assert "already exists with different path" in data2["detail"]
|
||||
assert test_project_path1 in data2["detail"]
|
||||
assert test_project_path2 in data2["detail"]
|
||||
|
||||
# Clean up
|
||||
try:
|
||||
await project_service.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_with_delete_notes_false(test_config, client, project_service):
|
||||
"""Test that removing a project with delete_notes=False leaves directory intact."""
|
||||
# Create a test project with actual directory
|
||||
test_project_name = "test-remove-keep-files"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_path = Path(temp_dir) / "test-project"
|
||||
test_path.mkdir()
|
||||
test_file = test_path / "test.md"
|
||||
test_file.write_text("# Test Note")
|
||||
|
||||
await project_service.add_project(test_project_name, str(test_path))
|
||||
|
||||
# Remove the project without deleting files (default)
|
||||
response = await client.delete(f"/projects/{test_project_name}")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify project is removed from config/db
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
|
||||
# Verify directory still exists
|
||||
assert test_path.exists()
|
||||
assert test_file.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_with_delete_notes_true(test_config, client, project_service):
|
||||
"""Test that removing a project with delete_notes=True deletes the directory."""
|
||||
# Create a test project with actual directory
|
||||
test_project_name = "test-remove-delete-files"
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
test_path = Path(temp_dir) / "test-project"
|
||||
test_path.mkdir()
|
||||
test_file = test_path / "test.md"
|
||||
test_file.write_text("# Test Note")
|
||||
|
||||
await project_service.add_project(test_project_name, str(test_path))
|
||||
|
||||
# Remove the project with delete_notes=True
|
||||
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify project is removed from config/db
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
|
||||
# Verify directory is deleted
|
||||
assert not test_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_delete_notes_nonexistent_directory(
|
||||
test_config, client, project_service
|
||||
):
|
||||
"""Test that removing a project with delete_notes=True handles missing directory gracefully."""
|
||||
# Create a project pointing to a non-existent path
|
||||
test_project_name = "test-remove-missing-dir"
|
||||
test_path = "/tmp/this-directory-does-not-exist-12345"
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
# Remove the project with delete_notes=True (should not fail even if dir doesn't exist)
|
||||
response = await client.delete(f"/projects/{test_project_name}?delete_notes=true")
|
||||
|
||||
# Should succeed
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
# Verify project is removed
|
||||
removed_project = await project_service.get_project(test_project_name)
|
||||
assert removed_project is None
|
||||
@@ -1,55 +0,0 @@
|
||||
"""Tests for project router operation endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_project_info_additional(client, test_graph, project_url):
|
||||
"""Test additional fields in the project info endpoint."""
|
||||
# Call the endpoint
|
||||
response = await client.get(f"{project_url}/project/info")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check specific fields we're interested in
|
||||
assert "available_projects" in data
|
||||
assert isinstance(data["available_projects"], dict)
|
||||
|
||||
# Get a project from the list
|
||||
for project_name, project_info in data["available_projects"].items():
|
||||
# Verify project structure
|
||||
assert "path" in project_info
|
||||
assert "active" in project_info
|
||||
assert "is_default" in project_info
|
||||
break # Just check the first one for structure
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_list_additional(client, project_url):
|
||||
"""Test additional fields in the project list endpoint."""
|
||||
# Call the endpoint
|
||||
response = await client.get("/projects/projects")
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify projects list structure in more detail
|
||||
assert "projects" in data
|
||||
assert len(data["projects"]) > 0
|
||||
|
||||
# Verify the default project is identified
|
||||
default_project = data["default_project"]
|
||||
assert default_project
|
||||
|
||||
# Verify the default_project appears in the projects list and is marked as default
|
||||
default_in_list = False
|
||||
for project in data["projects"]:
|
||||
if project["name"] == default_project:
|
||||
assert project["is_default"] is True
|
||||
default_in_list = True
|
||||
break
|
||||
|
||||
assert default_in_list, "Default project should appear in the projects list"
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Tests for the prompt router endpoints."""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.services.context_service import ContextService
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def context_service(entity_repository, search_service, observation_repository):
|
||||
"""Create a real context service for testing."""
|
||||
return ContextService(entity_repository, search_service, observation_repository)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continue_conversation_endpoint(
|
||||
client: AsyncClient,
|
||||
entity_service,
|
||||
search_service,
|
||||
context_service,
|
||||
entity_repository,
|
||||
test_graph,
|
||||
project_url,
|
||||
):
|
||||
"""Test the continue_conversation endpoint with real services."""
|
||||
# Create request data
|
||||
request_data = {
|
||||
"topic": "Root", # This should match our test entity in test_graph
|
||||
"timeframe": "7d",
|
||||
"depth": 1,
|
||||
"related_items_limit": 2,
|
||||
}
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.post(f"{project_url}/prompt/continue-conversation", json=request_data)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "prompt" in result
|
||||
assert "context" in result
|
||||
|
||||
# Check content of context
|
||||
context = result["context"]
|
||||
assert context["topic"] == "Root"
|
||||
assert context["timeframe"] == "7d"
|
||||
assert context["has_results"] is True
|
||||
assert len(context["hierarchical_results"]) > 0
|
||||
|
||||
# Check content of prompt
|
||||
prompt = result["prompt"]
|
||||
assert "Continuing conversation on: Root" in prompt
|
||||
assert "memory retrieval session" in prompt
|
||||
|
||||
# Test without topic - should use recent activity
|
||||
request_data = {"timeframe": "1d", "depth": 1, "related_items_limit": 2}
|
||||
|
||||
response = await client.post(f"{project_url}/prompt/continue-conversation", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "Recent Activity" in result["context"]["topic"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_prompt_endpoint(
|
||||
client: AsyncClient, entity_service, search_service, test_graph, project_url
|
||||
):
|
||||
"""Test the search_prompt endpoint with real services."""
|
||||
# Create request data
|
||||
request_data = {
|
||||
"query": "Root", # This should match our test entity
|
||||
"timeframe": "7d",
|
||||
}
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.post(f"{project_url}/prompt/search", json=request_data)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "prompt" in result
|
||||
assert "context" in result
|
||||
|
||||
# Check content of context
|
||||
context = result["context"]
|
||||
assert context["query"] == "Root"
|
||||
assert context["timeframe"] == "7d"
|
||||
assert context["has_results"] is True
|
||||
assert len(context["results"]) > 0
|
||||
|
||||
# Check content of prompt
|
||||
prompt = result["prompt"]
|
||||
assert 'Search Results for: "Root"' in prompt
|
||||
assert "This is a memory search session" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_prompt_no_results(
|
||||
client: AsyncClient, entity_service, search_service, project_url
|
||||
):
|
||||
"""Test the search_prompt endpoint with a query that returns no results."""
|
||||
# Create request data with a query that shouldn't match anything
|
||||
request_data = {"query": "NonExistentQuery12345", "timeframe": "7d"}
|
||||
|
||||
# Call the endpoint
|
||||
response = await client.post(f"{project_url}/prompt/search", json=request_data)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Check content of context
|
||||
context = result["context"]
|
||||
assert context["query"] == "NonExistentQuery12345"
|
||||
assert context["has_results"] is False
|
||||
assert len(context["results"]) == 0
|
||||
|
||||
# Check content of prompt
|
||||
prompt = result["prompt"]
|
||||
assert 'Search Results for: "NonExistentQuery12345"' in prompt
|
||||
assert "I couldn't find any results for this query" in prompt
|
||||
assert "Opportunity to Capture Knowledge" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling(client: AsyncClient, monkeypatch, project_url):
|
||||
"""Test error handling in the endpoints by breaking the template loader."""
|
||||
|
||||
# Patch the template loader to raise an exception
|
||||
def mock_render(*args, **kwargs):
|
||||
raise Exception("Template error")
|
||||
|
||||
# Apply the patch
|
||||
monkeypatch.setattr("basic_memory.api.template_loader.TemplateLoader.render", mock_render)
|
||||
|
||||
# Test continue_conversation error handling
|
||||
response = await client.post(
|
||||
f"{project_url}/prompt/continue-conversation",
|
||||
json={"topic": "test error", "timeframe": "7d"},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "detail" in response.json()
|
||||
assert "Template error" in response.json()["detail"]
|
||||
|
||||
# Test search_prompt error handling
|
||||
response = await client.post(
|
||||
f"{project_url}/prompt/search", json={"query": "test error", "timeframe": "7d"}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "detail" in response.json()
|
||||
assert "Template error" in response.json()["detail"]
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Test that relation resolution happens in the background."""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.api.routers.knowledge_router import resolve_relations_background
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_relations_background_success():
|
||||
"""Test that background relation resolution calls sync service correctly."""
|
||||
|
||||
class StubSyncService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[int] = []
|
||||
|
||||
async def resolve_relations(self, *, entity_id: int) -> None:
|
||||
self.calls.append(entity_id)
|
||||
|
||||
sync_service = StubSyncService()
|
||||
|
||||
entity_id = 123
|
||||
entity_permalink = "test/entity"
|
||||
|
||||
# Call the background function
|
||||
await resolve_relations_background(sync_service, entity_id, entity_permalink)
|
||||
|
||||
# Verify sync service was called with the entity_id
|
||||
assert sync_service.calls == [entity_id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_relations_background_handles_errors():
|
||||
"""Test that background relation resolution handles errors gracefully."""
|
||||
|
||||
class StubSyncService:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[int] = []
|
||||
|
||||
async def resolve_relations(self, *, entity_id: int) -> None:
|
||||
self.calls.append(entity_id)
|
||||
raise Exception("Test error")
|
||||
|
||||
sync_service = StubSyncService()
|
||||
|
||||
entity_id = 123
|
||||
entity_permalink = "test/entity"
|
||||
|
||||
# Call should not raise - errors are logged
|
||||
await resolve_relations_background(sync_service, entity_id, entity_permalink)
|
||||
|
||||
# Verify sync service was called
|
||||
assert sync_service.calls == [entity_id]
|
||||
@@ -1,454 +0,0 @@
|
||||
"""Tests for resource router endpoints."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.schemas import EntityResponse
|
||||
from basic_memory.utils import normalize_newlines
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_content(client, project_config, entity_repository, project_url):
|
||||
"""Test getting content by permalink."""
|
||||
# Create a test file
|
||||
content = "# Test Content\n\nThis is a test file."
|
||||
test_file = Path(project_config.home) / "test" / "test.md"
|
||||
test_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
test_file.write_text(content)
|
||||
|
||||
# Create entity referencing the file
|
||||
entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md", # Relative to config.home
|
||||
"content_type": "text/markdown",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
# Test getting the content
|
||||
response = await client.get(f"{project_url}/resource/{entity.permalink}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert response.text == normalize_newlines(content)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_pagination(client, project_config, entity_repository, project_url):
|
||||
"""Test getting content by permalink with pagination."""
|
||||
# Create a test file
|
||||
content = "# Test Content\n\nThis is a test file."
|
||||
test_file = Path(project_config.home) / "test" / "test.md"
|
||||
test_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
test_file.write_text(content)
|
||||
|
||||
# Create entity referencing the file
|
||||
entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md", # Relative to config.home
|
||||
"content_type": "text/markdown",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
# Test getting the content
|
||||
response = await client.get(
|
||||
f"{project_url}/resource/{entity.permalink}", params={"page": 1, "page_size": 1}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert response.text == normalize_newlines(content)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_by_title(client, project_config, entity_repository, project_url):
|
||||
"""Test getting content by permalink."""
|
||||
# Create a test file
|
||||
content = "# Test Content\n\nThis is a test file."
|
||||
test_file = Path(project_config.home) / "test" / "test.md"
|
||||
test_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
test_file.write_text(content)
|
||||
|
||||
# Create entity referencing the file
|
||||
entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Test Entity",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/test",
|
||||
"file_path": "test/test.md", # Relative to config.home
|
||||
"content_type": "text/markdown",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
# Test getting the content
|
||||
response = await client.get(f"{project_url}/resource/{entity.title}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_missing_entity(client, project_url):
|
||||
"""Test 404 when entity doesn't exist."""
|
||||
response = await client.get(f"{project_url}/resource/does/not/exist")
|
||||
assert response.status_code == 404
|
||||
assert "Resource not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_missing_file(client, project_config, entity_repository, project_url):
|
||||
"""Test 404 when file doesn't exist."""
|
||||
# Create entity referencing non-existent file
|
||||
entity = await entity_repository.create(
|
||||
{
|
||||
"title": "Missing File",
|
||||
"entity_type": "test",
|
||||
"permalink": "test/missing",
|
||||
"file_path": "test/missing.md",
|
||||
"content_type": "text/markdown",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.get(f"{project_url}/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, project_config, entity_repository, project_url):
|
||||
"""Test getting content by observation permalink."""
|
||||
# Create entity
|
||||
content = "# Test Content\n\n- [note] an observation."
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content}",
|
||||
}
|
||||
response = await client.post(f"{project_url}/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"{project_url}/resource/{observation.permalink}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
normalize_newlines(
|
||||
"""
|
||||
---
|
||||
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, project_config, entity_repository, project_url):
|
||||
"""Test getting content by permalink match."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post(f"{project_url}/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(f"{project_url}/resource/test/*")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
normalize_newlines(
|
||||
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_entities_pagination(
|
||||
client, project_config, entity_repository, project_url, db_backend
|
||||
):
|
||||
"""Test getting content by permalink match."""
|
||||
if db_backend == "postgres":
|
||||
pytest.skip(
|
||||
"Pagination differs: relations expand to multiple entities, ordering is undefined"
|
||||
)
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
assert entity1
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity2 = EntityResponse(**entity_response)
|
||||
|
||||
assert len(entity2.relations) == 1
|
||||
|
||||
# Test getting second result
|
||||
response = await client.get(
|
||||
f"{project_url}/resource/test/*", params={"page": 2, "page_size": 1}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
normalize_newlines(
|
||||
"""
|
||||
---
|
||||
title: Related Entity
|
||||
type: test
|
||||
permalink: test/related-entity
|
||||
---
|
||||
|
||||
# Related Content
|
||||
- links to [[Test Entity]]
|
||||
""".strip()
|
||||
)
|
||||
in response.text
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_relation(client, project_config, entity_repository, project_url):
|
||||
"""Test getting content by relation permalink."""
|
||||
# Create entity
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/entities", json=data)
|
||||
entity_response = response.json()
|
||||
entity1 = EntityResponse(**entity_response)
|
||||
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
response = await client.post(f"{project_url}/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"{project_url}/resource/{relation.permalink}")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/markdown; charset=utf-8"
|
||||
assert (
|
||||
normalize_newlines(
|
||||
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_put_resource_new_file(
|
||||
client, project_config, entity_repository, search_repository, project_url
|
||||
):
|
||||
"""Test creating a new file via PUT."""
|
||||
# Test data
|
||||
file_path = "visualizations/test.canvas"
|
||||
canvas_data = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"type": "text",
|
||||
"text": "Test node content",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"width": 400,
|
||||
"height": 300,
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
# Make sure the file doesn't exist yet
|
||||
full_path = Path(project_config.home) / file_path
|
||||
if full_path.exists():
|
||||
full_path.unlink()
|
||||
|
||||
# Execute PUT request
|
||||
response = await client.put(
|
||||
f"{project_url}/resource/{file_path}", json=json.dumps(canvas_data, indent=2)
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 201
|
||||
response_data = response.json()
|
||||
assert response_data["file_path"] == file_path
|
||||
assert "checksum" in response_data
|
||||
assert "size" in response_data
|
||||
|
||||
# Verify file was created
|
||||
full_path = Path(project_config.home) / file_path
|
||||
assert full_path.exists()
|
||||
|
||||
# Verify file content
|
||||
file_content = full_path.read_text(encoding="utf-8")
|
||||
assert json.loads(file_content) == canvas_data
|
||||
|
||||
# Verify entity was created in DB
|
||||
entity = await entity_repository.get_by_file_path(file_path)
|
||||
assert entity is not None
|
||||
assert entity.entity_type == "canvas"
|
||||
assert entity.content_type == "application/json"
|
||||
|
||||
# Verify entity was indexed for search
|
||||
search_results = await search_repository.search(title="test.canvas")
|
||||
assert len(search_results) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_resource_update_existing(client, project_config, entity_repository, project_url):
|
||||
"""Test updating an existing file via PUT."""
|
||||
# Create an initial file and entity
|
||||
file_path = "visualizations/update-test.canvas"
|
||||
full_path = Path(project_config.home) / file_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
initial_data = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "initial",
|
||||
"type": "text",
|
||||
"text": "Initial content",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 200,
|
||||
"height": 100,
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
full_path.write_text(json.dumps(initial_data))
|
||||
|
||||
# Create the initial entity
|
||||
initial_entity = await entity_repository.create(
|
||||
{
|
||||
"title": "update-test.canvas",
|
||||
"entity_type": "canvas",
|
||||
"file_path": file_path,
|
||||
"content_type": "application/json",
|
||||
"checksum": "initial123",
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
)
|
||||
|
||||
# New data for update
|
||||
updated_data = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "updated",
|
||||
"type": "text",
|
||||
"text": "Updated content",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 300,
|
||||
"height": 200,
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
# Execute PUT request to update
|
||||
response = await client.put(
|
||||
f"{project_url}/resource/{file_path}", json=json.dumps(updated_data, indent=2)
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify file was updated
|
||||
updated_content = full_path.read_text(encoding="utf-8")
|
||||
assert json.loads(updated_content) == updated_data
|
||||
|
||||
# Verify entity was updated
|
||||
updated_entity = await entity_repository.get_by_file_path(file_path)
|
||||
assert updated_entity.id == initial_entity.id # Same entity, updated
|
||||
assert updated_entity.checksum != initial_entity.checksum # Checksum changed
|
||||
@@ -1,179 +0,0 @@
|
||||
"""Tests for search router."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.schemas import Entity as EntitySchema
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResponse
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def indexed_entity(full_entity, search_service):
|
||||
"""Create an entity and index it."""
|
||||
await search_service.index_entity(full_entity)
|
||||
return full_entity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_basic(client, indexed_entity, project_url):
|
||||
"""Test basic text search."""
|
||||
response = await client.post(f"{project_url}/search/", json={"text": "search"})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 3
|
||||
|
||||
found = False
|
||||
for r in search_results.results:
|
||||
if r.type == SearchItemType.ENTITY.value:
|
||||
assert r.permalink == indexed_entity.permalink
|
||||
found = True
|
||||
|
||||
assert found, "Expected to find indexed entity in results"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_basic_pagination(client, indexed_entity, project_url):
|
||||
"""Test basic text search."""
|
||||
response = await client.post(
|
||||
f"{project_url}/search/?page=3&page_size=1", json={"text": "search"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 1
|
||||
|
||||
assert search_results.current_page == 3
|
||||
assert search_results.page_size == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_entity_type_filter(client, indexed_entity, project_url):
|
||||
"""Test search with type filter."""
|
||||
# Should find with correct type
|
||||
response = await client.post(
|
||||
f"{project_url}/search/",
|
||||
json={"text": "test", "entity_types": [SearchItemType.ENTITY.value]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) > 0
|
||||
|
||||
# Should find with relation type
|
||||
response = await client.post(
|
||||
f"{project_url}/search/",
|
||||
json={"text": "test", "entity_types": [SearchItemType.RELATION.value]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_type_filter(client, indexed_entity, project_url):
|
||||
"""Test search with entity type filter."""
|
||||
# Should find with correct entity type
|
||||
response = await client.post(f"{project_url}/search/", json={"text": "test", "types": ["test"]})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 1
|
||||
|
||||
# Should not find with wrong entity type
|
||||
response = await client.post(f"{project_url}/search/", json={"text": "test", "types": ["note"]})
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_date_filter(client, indexed_entity, project_url):
|
||||
"""Test search with date filter."""
|
||||
# Should find with past date
|
||||
past_date = datetime(2020, 1, 1, tzinfo=timezone.utc)
|
||||
response = await client.post(
|
||||
f"{project_url}/search/", json={"text": "test", "after_date": past_date.isoformat()}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
|
||||
# Should not find with future date
|
||||
future_date = datetime(2030, 1, 1, tzinfo=timezone.utc)
|
||||
response = await client.post(
|
||||
f"{project_url}/search/", json={"text": "test", "after_date": future_date.isoformat()}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty(search_service, client, project_url):
|
||||
"""Test search with no matches."""
|
||||
response = await client.post(f"{project_url}/search/", json={"text": "nonexistent"})
|
||||
assert response.status_code == 200
|
||||
search_result = SearchResponse.model_validate(response.json())
|
||||
assert len(search_result.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reindex(
|
||||
client, search_service, entity_service, session_maker, project_url, app_config
|
||||
):
|
||||
"""Test reindex endpoint."""
|
||||
# Skip for Postgres - needs investigation of database connection isolation
|
||||
from basic_memory.config import DatabaseBackend
|
||||
|
||||
if app_config.database_backend == DatabaseBackend.POSTGRES:
|
||||
pytest.skip("Not yet supported for Postgres - database connection isolation issue")
|
||||
|
||||
# Create test entity and document
|
||||
await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="TestEntity1",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
),
|
||||
)
|
||||
|
||||
# Clear search index
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
await session.execute(text("DELETE FROM search_index"))
|
||||
await session.commit()
|
||||
|
||||
# Verify nothing is searchable
|
||||
response = await client.post(f"{project_url}/search/", json={"text": "test"})
|
||||
search_results = SearchResponse.model_validate(response.json())
|
||||
assert len(search_results.results) == 0
|
||||
|
||||
# Trigger reindex
|
||||
reindex_response = await client.post(f"{project_url}/search/reindex")
|
||||
assert reindex_response.status_code == 200
|
||||
assert reindex_response.json()["status"] == "ok"
|
||||
|
||||
# Verify content is searchable again
|
||||
search_response = await client.post(f"{project_url}/search/", json={"text": "test"})
|
||||
search_results = SearchResponse.model_validate(search_response.json())
|
||||
assert len(search_results.results) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_filters(client, indexed_entity, project_url):
|
||||
"""Test search with multiple filters combined."""
|
||||
response = await client.post(
|
||||
f"{project_url}/search/",
|
||||
json={
|
||||
"text": "test",
|
||||
"entity_types": [SearchItemType.ENTITY.value],
|
||||
"types": ["test"],
|
||||
"after_date": datetime(2020, 1, 1, tzinfo=timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
search_result = SearchResponse.model_validate(response.json())
|
||||
assert len(search_result.results) == 1
|
||||
result = search_result.results[0]
|
||||
assert result.permalink == indexed_entity.permalink
|
||||
assert result.type == SearchItemType.ENTITY.value
|
||||
assert result.metadata["entity_type"] == "test"
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Tests for the search template rendering."""
|
||||
|
||||
import datetime
|
||||
import pytest
|
||||
|
||||
from basic_memory.api.template_loader import TemplateLoader
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def template_loader():
|
||||
"""Return a TemplateLoader instance for testing."""
|
||||
return TemplateLoader()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_result():
|
||||
"""Create a sample SearchResult for testing."""
|
||||
return SearchResult(
|
||||
title="Test Search Result",
|
||||
type=SearchItemType.ENTITY,
|
||||
permalink="test/search-result",
|
||||
score=0.95,
|
||||
content="This is a test search result with some content.",
|
||||
file_path="/path/to/test/search-result.md",
|
||||
metadata={"created_at": datetime.datetime(2023, 2, 1, 12, 0)},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context_with_results(search_result):
|
||||
"""Create a sample context with search results."""
|
||||
return {
|
||||
"query": "test query",
|
||||
"timeframe": "30d",
|
||||
"has_results": True,
|
||||
"result_count": 1,
|
||||
"results": [search_result],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context_without_results():
|
||||
"""Create a sample context without search results."""
|
||||
return {
|
||||
"query": "empty query",
|
||||
"timeframe": None,
|
||||
"has_results": False,
|
||||
"result_count": 0,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_results(template_loader, context_with_results):
|
||||
"""Test rendering the search template with results."""
|
||||
result = await template_loader.render("prompts/search.hbs", context_with_results)
|
||||
|
||||
# Check that key elements are present
|
||||
assert 'Search Results for: "test query" (after 30d)' in result
|
||||
assert "1.0. Test Search Result" in result
|
||||
assert "Type**: entity" in result
|
||||
assert "Relevance Score**: 0.95" in result
|
||||
assert "This is a test search result with some content." in result
|
||||
assert 'read_note("test/search-result")' in result
|
||||
assert "Next Steps" in result
|
||||
assert "Synthesize and Capture Knowledge" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_without_results(template_loader, context_without_results):
|
||||
"""Test rendering the search template without results."""
|
||||
result = await template_loader.render("prompts/search.hbs", context_without_results)
|
||||
|
||||
# Check that key elements are present
|
||||
assert 'Search Results for: "empty query"' in result
|
||||
assert "I couldn't find any results for this query." in result
|
||||
assert "Opportunity to Capture Knowledge!" in result
|
||||
assert "write_note(" in result
|
||||
assert 'title="Empty query"' in result
|
||||
assert "Other Suggestions" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_search_results(template_loader):
|
||||
"""Test rendering the search template with multiple results."""
|
||||
# Create multiple search results
|
||||
results = []
|
||||
for i in range(1, 6): # Create 5 results
|
||||
results.append(
|
||||
SearchResult(
|
||||
title=f"Search Result {i}",
|
||||
type=SearchItemType.ENTITY,
|
||||
permalink=f"test/result-{i}",
|
||||
score=1.0 - (i * 0.1), # Decreasing scores
|
||||
content=f"Content for result {i}",
|
||||
file_path=f"/path/to/result-{i}.md",
|
||||
metadata={},
|
||||
)
|
||||
)
|
||||
|
||||
context = {
|
||||
"query": "multiple results",
|
||||
"timeframe": None,
|
||||
"has_results": True,
|
||||
"result_count": len(results),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
result = await template_loader.render("prompts/search.hbs", context)
|
||||
|
||||
# Check that all results are rendered
|
||||
for i in range(1, 6):
|
||||
assert f"{i}.0. Search Result {i}" in result
|
||||
assert f"Content for result {i}" in result
|
||||
assert f'read_note("test/result-{i}")' in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capitalization_in_write_note_template(template_loader, context_with_results):
|
||||
"""Test that the query is capitalized in the write_note template."""
|
||||
result = await template_loader.render("prompts/search.hbs", context_with_results)
|
||||
|
||||
# The query should be capitalized in the suggested write_note call
|
||||
assert "Synthesis of Test query Information" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeframe_display(template_loader):
|
||||
"""Test that the timeframe is displayed correctly when present, and not when absent."""
|
||||
# Context with timeframe
|
||||
context_with_timeframe = {
|
||||
"query": "with timeframe",
|
||||
"timeframe": "7d",
|
||||
"has_results": True,
|
||||
"result_count": 0,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
result_with_timeframe = await template_loader.render(
|
||||
"prompts/search.hbs", context_with_timeframe
|
||||
)
|
||||
assert 'Search Results for: "with timeframe" (after 7d)' in result_with_timeframe
|
||||
|
||||
# Context without timeframe
|
||||
context_without_timeframe = {
|
||||
"query": "without timeframe",
|
||||
"timeframe": None,
|
||||
"has_results": True,
|
||||
"result_count": 0,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
result_without_timeframe = await template_loader.render(
|
||||
"prompts/search.hbs", context_without_timeframe
|
||||
)
|
||||
assert 'Search Results for: "without timeframe"' in result_without_timeframe
|
||||
assert 'Search Results for: "without timeframe" (after' not in result_without_timeframe
|
||||
@@ -1,219 +0,0 @@
|
||||
"""Tests for the template loader functionality."""
|
||||
|
||||
import datetime
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from basic_memory.api.template_loader import TemplateLoader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_template_dir(tmpdir):
|
||||
"""Create a temporary directory for test templates."""
|
||||
template_dir = tmpdir.mkdir("templates").mkdir("prompts")
|
||||
return template_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custom_template_loader(temp_template_dir):
|
||||
"""Return a TemplateLoader instance with a custom template directory."""
|
||||
return TemplateLoader(str(temp_template_dir))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_template(temp_template_dir):
|
||||
"""Create a simple test template."""
|
||||
template_path = temp_template_dir / "simple.hbs"
|
||||
template_path.write_text("Hello, {{name}}!", encoding="utf-8")
|
||||
return "simple.hbs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_simple_template(custom_template_loader, simple_template):
|
||||
"""Test rendering a simple template."""
|
||||
context = {"name": "World"}
|
||||
result = await custom_template_loader.render(simple_template, context)
|
||||
assert result == "Hello, World!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_cache(custom_template_loader, simple_template):
|
||||
"""Test that templates are cached."""
|
||||
context = {"name": "World"}
|
||||
|
||||
# First render, should load template
|
||||
await custom_template_loader.render(simple_template, context)
|
||||
|
||||
# Check that template is in cache
|
||||
assert simple_template in custom_template_loader.template_cache
|
||||
|
||||
# Modify the template file - shouldn't affect the cached version
|
||||
template_path = Path(custom_template_loader.template_dir) / simple_template
|
||||
template_path.write_text("Goodbye, {{name}}!", encoding="utf-8")
|
||||
|
||||
# Second render, should use cached template
|
||||
result = await custom_template_loader.render(simple_template, context)
|
||||
assert result == "Hello, World!"
|
||||
|
||||
# Clear cache and render again - should use updated template
|
||||
custom_template_loader.clear_cache()
|
||||
assert simple_template not in custom_template_loader.template_cache
|
||||
|
||||
result = await custom_template_loader.render(simple_template, context)
|
||||
assert result == "Goodbye, World!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_date_helper(custom_template_loader, temp_template_dir):
|
||||
# Test date helper
|
||||
date_path = temp_template_dir / "date.hbs"
|
||||
date_path.write_text("{{date timestamp}}", encoding="utf-8")
|
||||
date_result = await custom_template_loader.render(
|
||||
"date.hbs", {"timestamp": datetime.datetime(2023, 1, 1, 12, 30)}
|
||||
)
|
||||
assert "2023-01-01" in date_result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_helper(custom_template_loader, temp_template_dir):
|
||||
# Test default helper
|
||||
default_path = temp_template_dir / "default.hbs"
|
||||
default_path.write_text("{{default null 'default-value'}}", encoding="utf-8")
|
||||
default_result = await custom_template_loader.render("default.hbs", {"null": None})
|
||||
assert default_result == "default-value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capitalize_helper(custom_template_loader, temp_template_dir):
|
||||
# Test capitalize helper
|
||||
capitalize_path = temp_template_dir / "capitalize.hbs"
|
||||
capitalize_path.write_text("{{capitalize 'test'}}", encoding="utf-8")
|
||||
capitalize_result = await custom_template_loader.render("capitalize.hbs", {})
|
||||
assert capitalize_result == "Test"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_helper(custom_template_loader, temp_template_dir):
|
||||
# Test size helper
|
||||
size_path = temp_template_dir / "size.hbs"
|
||||
size_path.write_text("{{size collection}}", encoding="utf-8")
|
||||
size_result = await custom_template_loader.render("size.hbs", {"collection": [1, 2, 3]})
|
||||
assert size_result == "3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_helper(custom_template_loader, temp_template_dir):
|
||||
# Test json helper
|
||||
json_path = temp_template_dir / "json.hbs"
|
||||
json_path.write_text("{{json data}}", encoding="utf-8")
|
||||
json_result = await custom_template_loader.render("json.hbs", {"data": {"key": "value"}})
|
||||
assert json_result == '{"key": "value"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_less_than_helper(custom_template_loader, temp_template_dir):
|
||||
# Test lt (less than) helper
|
||||
lt_path = temp_template_dir / "lt.hbs"
|
||||
lt_path.write_text("{{#if_cond (lt 2 3)}}true{{else}}false{{/if_cond}}", encoding="utf-8")
|
||||
lt_result = await custom_template_loader.render("lt.hbs", {})
|
||||
assert lt_result == "true"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_not_found(custom_template_loader):
|
||||
"""Test that FileNotFoundError is raised when a template doesn't exist."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await custom_template_loader.render("non_existent_template.hbs", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extension_handling(custom_template_loader, temp_template_dir):
|
||||
"""Test that template extensions are handled correctly."""
|
||||
# Create template with .hbs extension
|
||||
template_path = temp_template_dir / "test_extension.hbs"
|
||||
template_path.write_text("Template with extension: {{value}}", encoding="utf-8")
|
||||
|
||||
# Test accessing with full extension
|
||||
result = await custom_template_loader.render("test_extension.hbs", {"value": "works"})
|
||||
assert result == "Template with extension: works"
|
||||
|
||||
# Test accessing without extension
|
||||
result = await custom_template_loader.render("test_extension", {"value": "also works"})
|
||||
assert result == "Template with extension: also works"
|
||||
|
||||
# Test accessing with wrong extension gets converted
|
||||
template_path = temp_template_dir / "liquid_template.hbs"
|
||||
template_path.write_text("Liquid template: {{value}}", encoding="utf-8")
|
||||
|
||||
result = await custom_template_loader.render("liquid_template.liquid", {"value": "converted"})
|
||||
assert result == "Liquid template: converted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedent_helper(custom_template_loader, temp_template_dir):
|
||||
"""Test the dedent helper for text blocks."""
|
||||
dedent_path = temp_template_dir / "dedent.hbs"
|
||||
|
||||
# Create a template with indented text blocks
|
||||
template_content = """Before
|
||||
{{#dedent}}
|
||||
This is indented text
|
||||
with nested indentation
|
||||
that should be dedented
|
||||
while preserving relative indentation
|
||||
{{/dedent}}
|
||||
After"""
|
||||
|
||||
dedent_path.write_text(template_content, encoding="utf-8")
|
||||
|
||||
# Render the template
|
||||
result = await custom_template_loader.render("dedent.hbs", {})
|
||||
|
||||
# Print the actual output for debugging
|
||||
print(f"Dedent helper result: {repr(result)}")
|
||||
|
||||
# Check that the indentation is properly removed
|
||||
assert "This is indented text" in result
|
||||
assert "with nested indentation" in result
|
||||
assert "that should be dedented" in result
|
||||
assert "while preserving relative indentation" in result
|
||||
assert "Before" in result
|
||||
assert "After" in result
|
||||
|
||||
# Check that relative indentation is preserved
|
||||
assert result.find("with nested indentation") > result.find("This is indented text")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_dedent_helper(custom_template_loader, temp_template_dir):
|
||||
"""Test the dedent helper with nested content."""
|
||||
dedent_path = temp_template_dir / "nested_dedent.hbs"
|
||||
|
||||
# Create a template with nested indented blocks
|
||||
template_content = """
|
||||
{{#each items}}
|
||||
{{#dedent}}
|
||||
--- Item {{this}}
|
||||
|
||||
Details for item {{this}}
|
||||
- Indented detail 1
|
||||
- Indented detail 2
|
||||
{{/dedent}}
|
||||
{{/each}}"""
|
||||
|
||||
dedent_path.write_text(template_content, encoding="utf-8")
|
||||
|
||||
# Render the template
|
||||
result = await custom_template_loader.render("nested_dedent.hbs", {"items": [1, 2]})
|
||||
|
||||
# Print the actual output for debugging
|
||||
print(f"Actual result: {repr(result)}")
|
||||
|
||||
# Use a more flexible assertion that checks individual components
|
||||
# instead of exact string matching
|
||||
assert "--- Item 1" in result
|
||||
assert "Details for item 1" in result
|
||||
assert "- Indented detail 1" in result
|
||||
assert "--- Item 2" in result
|
||||
assert "Details for item 2" in result
|
||||
assert "- Indented detail 2" in result
|
||||
@@ -1,203 +0,0 @@
|
||||
"""Tests for additional template loader helpers."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from basic_memory.api.template_loader import TemplateLoader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_template_dir(tmpdir):
|
||||
"""Create a temporary directory for test templates."""
|
||||
template_dir = tmpdir.mkdir("templates").mkdir("prompts")
|
||||
return template_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custom_template_loader(temp_template_dir):
|
||||
"""Return a TemplateLoader instance with a custom template directory."""
|
||||
return TemplateLoader(str(temp_template_dir))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_round_helper(custom_template_loader, temp_template_dir):
|
||||
"""Test the round helper for number formatting."""
|
||||
# Create template file
|
||||
round_path = temp_template_dir / "round.hbs"
|
||||
round_path.write_text(
|
||||
"{{round number}} {{round number 0}} {{round number 3}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Test with various values
|
||||
result = await custom_template_loader.render("round.hbs", {"number": 3.14159})
|
||||
assert result == "3.14 3.0 3.142" or result == "3.14 3 3.142"
|
||||
|
||||
# Test with non-numeric value
|
||||
result = await custom_template_loader.render("round.hbs", {"number": "not-a-number"})
|
||||
assert "not-a-number" in result
|
||||
|
||||
# Test with insufficient args
|
||||
empty_path = temp_template_dir / "round_empty.hbs"
|
||||
empty_path.write_text("{{round}}", encoding="utf-8")
|
||||
result = await custom_template_loader.render("round_empty.hbs", {})
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_date_helper_edge_cases(custom_template_loader, temp_template_dir):
|
||||
"""Test edge cases for the date helper."""
|
||||
# Create template file
|
||||
date_path = temp_template_dir / "date_edge.hbs"
|
||||
date_path.write_text(
|
||||
"{{date timestamp}} {{date timestamp '%Y'}} {{date string_date}} {{date invalid_date}} {{date}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Test with various values
|
||||
result = await custom_template_loader.render(
|
||||
"date_edge.hbs",
|
||||
{
|
||||
"timestamp": datetime(2023, 1, 1, 12, 30),
|
||||
"string_date": "2023-01-01T12:30:00",
|
||||
"invalid_date": "not-a-date",
|
||||
},
|
||||
)
|
||||
|
||||
assert "2023-01-01" in result
|
||||
assert "2023" in result # Custom format
|
||||
assert "not-a-date" in result # Invalid date passed through
|
||||
assert result.strip() != "" # Empty date case
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_helper_edge_cases(custom_template_loader, temp_template_dir):
|
||||
"""Test edge cases for the size helper."""
|
||||
# Create template file
|
||||
size_path = temp_template_dir / "size_edge.hbs"
|
||||
size_path.write_text(
|
||||
"{{size list}} {{size string}} {{size dict}} {{size null}} {{size}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Test with various values
|
||||
result = await custom_template_loader.render(
|
||||
"size_edge.hbs",
|
||||
{
|
||||
"list": [1, 2, 3, 4, 5],
|
||||
"string": "hello",
|
||||
"dict": {"a": 1, "b": 2, "c": 3},
|
||||
"null": None,
|
||||
},
|
||||
)
|
||||
|
||||
assert "5" in result # List size
|
||||
assert "hello".find("5") == -1 # String size should be 5
|
||||
assert "3" in result # Dict size
|
||||
assert "0" in result # Null size
|
||||
assert result.count("0") >= 2 # At least two zeros (null and empty args)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_math_helper(custom_template_loader, temp_template_dir):
|
||||
"""Test the math helper for basic arithmetic."""
|
||||
# Create template file
|
||||
math_path = temp_template_dir / "math.hbs"
|
||||
math_path.write_text(
|
||||
"{{math 5 '+' 3}} {{math 10 '-' 4}} {{math 6 '*' 7}} {{math 20 '/' 5}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Test basic operations
|
||||
result = await custom_template_loader.render("math.hbs", {})
|
||||
assert "8" in result # Addition
|
||||
assert "6" in result # Subtraction
|
||||
assert "42" in result # Multiplication
|
||||
assert "4" in result # Division
|
||||
|
||||
# Test with invalid operator
|
||||
invalid_op_path = temp_template_dir / "math_invalid_op.hbs"
|
||||
invalid_op_path.write_text("{{math 5 'invalid' 3}}", encoding="utf-8")
|
||||
result = await custom_template_loader.render("math_invalid_op.hbs", {})
|
||||
assert "Unsupported operator" in result
|
||||
|
||||
# Test with invalid numeric values
|
||||
invalid_num_path = temp_template_dir / "math_invalid_num.hbs"
|
||||
invalid_num_path.write_text("{{math 'not-a-number' '+' 3}}", encoding="utf-8")
|
||||
result = await custom_template_loader.render("math_invalid_num.hbs", {})
|
||||
assert "Math error" in result
|
||||
|
||||
# Test with insufficient arguments
|
||||
insufficient_path = temp_template_dir / "math_insufficient.hbs"
|
||||
insufficient_path.write_text("{{math 5 '+'}}", encoding="utf-8")
|
||||
result = await custom_template_loader.render("math_insufficient.hbs", {})
|
||||
assert "Insufficient arguments" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_if_cond_helper(custom_template_loader, temp_template_dir):
|
||||
"""Test the if_cond helper for conditionals."""
|
||||
# Create template file with true condition
|
||||
if_true_path = temp_template_dir / "if_true.hbs"
|
||||
if_true_path.write_text(
|
||||
"{{#if_cond (lt 5 10)}}True condition{{else}}False condition{{/if_cond}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Create template file with false condition
|
||||
if_false_path = temp_template_dir / "if_false.hbs"
|
||||
if_false_path.write_text(
|
||||
"{{#if_cond (lt 15 10)}}True condition{{else}}False condition{{/if_cond}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Test true condition
|
||||
result = await custom_template_loader.render("if_true.hbs", {})
|
||||
assert result == "True condition"
|
||||
|
||||
# Test false condition
|
||||
result = await custom_template_loader.render("if_false.hbs", {})
|
||||
assert result == "False condition"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lt_helper_edge_cases(custom_template_loader, temp_template_dir):
|
||||
"""Test edge cases for the lt (less than) helper."""
|
||||
# Create template file
|
||||
lt_path = temp_template_dir / "lt_edge.hbs"
|
||||
lt_path.write_text(
|
||||
"{{#if_cond (lt 'a' 'b')}}String LT True{{else}}String LT False{{/if_cond}} "
|
||||
"{{#if_cond (lt 'z' 'a')}}String LT2 True{{else}}String LT2 False{{/if_cond}} "
|
||||
"{{#if_cond (lt)}}Missing args True{{else}}Missing args False{{/if_cond}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Test with string values and missing args
|
||||
result = await custom_template_loader.render("lt_edge.hbs", {})
|
||||
assert "String LT True" in result # 'a' < 'b' is true
|
||||
assert "String LT2 False" in result # 'z' < 'a' is false
|
||||
assert "Missing args False" in result # Missing args should return false
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedent_helper_edge_case(custom_template_loader, temp_template_dir):
|
||||
"""Test an edge case for the dedent helper."""
|
||||
# Create template with empty dedent block
|
||||
empty_dedent_path = temp_template_dir / "empty_dedent.hbs"
|
||||
empty_dedent_path.write_text("{{#dedent}}{{/dedent}}", encoding="utf-8")
|
||||
|
||||
# Test empty block
|
||||
result = await custom_template_loader.render("empty_dedent.hbs", {})
|
||||
assert result == ""
|
||||
|
||||
# Test with complex content including lists
|
||||
complex_dedent_path = temp_template_dir / "complex_dedent.hbs"
|
||||
complex_dedent_path.write_text(
|
||||
"{{#dedent}}\n {{#each items}}\n - {{this}}\n {{/each}}\n{{/dedent}}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = await custom_template_loader.render("complex_dedent.hbs", {"items": [1, 2, 3]})
|
||||
assert "- 1" in result
|
||||
assert "- 2" in result
|
||||
assert "- 3" in result
|
||||
@@ -1,10 +1,48 @@
|
||||
"""Fixtures for V2 API tests."""
|
||||
|
||||
import pytest
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from basic_memory.deps import get_app_config, get_engine_factory
|
||||
from basic_memory.deps.services import get_task_scheduler
|
||||
from basic_memory.models import Project
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app(test_config, engine_factory, app_config) -> FastAPI:
|
||||
"""Create FastAPI test application."""
|
||||
from basic_memory.api.app import app
|
||||
|
||||
app.dependency_overrides[get_app_config] = lambda: app_config
|
||||
app.dependency_overrides[get_engine_factory] = lambda: engine_factory
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create client using ASGI transport - same as CLI will use."""
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def task_scheduler_spy(app: FastAPI) -> list[dict[str, Any]]:
|
||||
"""Capture scheduled task specs without executing them."""
|
||||
scheduled: list[dict[str, Any]] = []
|
||||
|
||||
class SchedulerSpy:
|
||||
def schedule(self, task_name: str, **payload: Any) -> None:
|
||||
scheduled.append({"task_name": task_name, "payload": payload})
|
||||
|
||||
app.dependency_overrides[get_task_scheduler] = lambda: SchedulerSpy()
|
||||
yield scheduled
|
||||
app.dependency_overrides.pop(get_task_scheduler, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def v2_project_url(test_project: Project) -> str:
|
||||
"""Create a URL prefix for v2 project-scoped routes using project external_id.
|
||||
|
||||
@@ -474,7 +474,9 @@ async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_
|
||||
async def test_import_missing_file(client: AsyncClient, v2_project_url: str):
|
||||
"""Test importing with missing file via v2 endpoint."""
|
||||
# Send a request without a file
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"directory": "test_folder"})
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/import/chatgpt", data={"directory": "test_folder"}
|
||||
)
|
||||
|
||||
# Check that the request was rejected
|
||||
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for V2 knowledge graph API routes (ID-based endpoints)."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
@@ -78,7 +80,9 @@ async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_identifier_with_source_path_no_fuzzy_match(client: AsyncClient, v2_project_url):
|
||||
async def test_resolve_identifier_with_source_path_no_fuzzy_match(
|
||||
client: AsyncClient, v2_project_url
|
||||
):
|
||||
"""Test that context-aware resolution also uses strict mode.
|
||||
|
||||
Even with source_path for context-aware resolution, nonexistent
|
||||
@@ -155,7 +159,9 @@ async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
|
||||
"content": "TestContent for V2",
|
||||
}
|
||||
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=data)
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/entities", json=data, params={"fast": False}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponseV2.model_validate(response.json())
|
||||
@@ -175,6 +181,34 @@ async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
|
||||
assert data["content"] in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_returns_content(client: AsyncClient, file_service, v2_project_url):
|
||||
"""Test creating an entity always returns file content with frontmatter."""
|
||||
data = {
|
||||
"title": "TestContentReturn",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content_type": "text/markdown",
|
||||
"content": "Body content for return test",
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/entities",
|
||||
json=data,
|
||||
params={"fast": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
# Content should always be populated with frontmatter
|
||||
assert entity.content is not None
|
||||
assert "---" in entity.content # frontmatter markers
|
||||
assert "title: TestContentReturn" in entity.content
|
||||
assert "type: note" in entity.content
|
||||
assert "permalink:" in entity.content
|
||||
assert data["content"] in entity.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entity_with_observations_and_relations(
|
||||
client: AsyncClient, file_service, v2_project_url
|
||||
@@ -192,7 +226,9 @@ async def test_create_entity_with_observations_and_relations(
|
||||
""",
|
||||
}
|
||||
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=data)
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/entities", json=data, params={"fast": False}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
entity = EntityResponseV2.model_validate(response.json())
|
||||
@@ -255,6 +291,93 @@ async def test_update_entity_by_id(
|
||||
assert "Original content" not in file_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_entity_by_id_fast_does_not_duplicate(
|
||||
client: AsyncClient, v2_project_url, entity_repository
|
||||
):
|
||||
"""Fast PUT updates the existing external_id without creating duplicates."""
|
||||
create_data = {
|
||||
"title": "07 - Get Started",
|
||||
"directory": "docs",
|
||||
"content": "Original content",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
assert response.status_code == 200
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
|
||||
update_data = {
|
||||
"title": "07 Get Started",
|
||||
"directory": "docs",
|
||||
"content": "Updated content",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}",
|
||||
json=update_data,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
entities = await entity_repository.find_all()
|
||||
assert len(entities) == 1
|
||||
assert entities[0].external_id == created_entity.external_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_entity_fast_returns_minimal_row(
|
||||
client: AsyncClient, v2_project_url, entity_repository
|
||||
):
|
||||
"""Fast PUT returns a minimal row and persists the external_id immediately."""
|
||||
external_id = str(uuid.uuid4())
|
||||
update_data = {
|
||||
"title": "FastPutEntity",
|
||||
"directory": "test",
|
||||
"content": """
|
||||
# FastPutEntity
|
||||
|
||||
## Observations
|
||||
- [note] This should be deferred
|
||||
|
||||
- related_to [[AnotherEntity]]
|
||||
""",
|
||||
}
|
||||
response = await client.put(
|
||||
f"{v2_project_url}/knowledge/entities/{external_id}",
|
||||
json=update_data,
|
||||
params={"fast": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
assert created_entity.external_id == external_id
|
||||
assert created_entity.observations == []
|
||||
assert created_entity.relations == []
|
||||
|
||||
db_entity = await entity_repository.get_by_external_id(external_id)
|
||||
assert db_entity is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_create_schedules_reindex_task(
|
||||
client: AsyncClient, v2_project_url, task_scheduler_spy
|
||||
):
|
||||
"""Fast create should enqueue a background reindex task."""
|
||||
start_count = len(task_scheduler_spy)
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "TaskScheduledEntity",
|
||||
"directory": "test",
|
||||
"content": "Content for task scheduling",
|
||||
},
|
||||
params={"fast": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(task_scheduler_spy) == start_count + 1
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
scheduled = task_scheduler_spy[-1]
|
||||
assert scheduled["task_name"] == "reindex_entity"
|
||||
assert scheduled["payload"]["entity_id"] == created_entity.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_entity_by_id_append(
|
||||
client: AsyncClient, file_service, v2_project_url, entity_repository
|
||||
|
||||
@@ -110,7 +110,7 @@ async def test_cloud_utils_fetch_and_exists_and_create_project(
|
||||
seen = {"create_payload": None}
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET" and request.url.path == "/proxy/projects/projects":
|
||||
if request.method == "GET" and request.url.path == "/proxy/v2/projects/":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
@@ -121,7 +121,7 @@ async def test_cloud_utils_fetch_and_exists_and_create_project(
|
||||
},
|
||||
)
|
||||
|
||||
if request.method == "POST" and request.url.path == "/proxy/projects/projects":
|
||||
if request.method == "POST" and request.url.path == "/proxy/v2/projects/":
|
||||
# httpx.Request doesn't have .json(); parse bytes payload.
|
||||
seen["create_payload"] = json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(
|
||||
|
||||
@@ -298,7 +298,7 @@ class TestProjectClient:
|
||||
}
|
||||
|
||||
async def mock_call_get(client, url, **kwargs):
|
||||
assert "/projects/projects" in url
|
||||
assert "/v2/projects" in url
|
||||
return mock_response
|
||||
|
||||
monkeypatch.setattr(project_mod, "call_get", mock_call_get)
|
||||
|
||||
@@ -116,6 +116,7 @@ def test_prompt_context_with_file_path_no_permalink():
|
||||
|
||||
# Create a mock context with a file that has no permalink (like a binary file)
|
||||
test_entity = EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
entity_id=1,
|
||||
type="entity",
|
||||
title="Test File",
|
||||
|
||||
@@ -109,7 +109,9 @@ async def test_update_existing_canvas(app, project_config, test_project):
|
||||
folder = "visualizations"
|
||||
|
||||
# Create initial canvas
|
||||
await canvas.fn(project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder)
|
||||
await canvas.fn(
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
|
||||
)
|
||||
|
||||
# Verify file exists
|
||||
file_path = Path(project_config.home) / folder / f"{title}.canvas"
|
||||
|
||||
@@ -14,7 +14,10 @@ async def test_read_note_by_title(app, test_project):
|
||||
"""Test reading a note by its title."""
|
||||
# First create a note
|
||||
await write_note.fn(
|
||||
project=test_project.name, title="Special Note", directory="test", content="Note content here"
|
||||
project=test_project.name,
|
||||
title="Special Note",
|
||||
directory="test",
|
||||
content="Note content here",
|
||||
)
|
||||
|
||||
# Should be able to read it by title
|
||||
|
||||
@@ -233,6 +233,7 @@ async def test_recent_activity_get_project_activity_timezone_normalization(monke
|
||||
{
|
||||
"primary_result": {
|
||||
"type": "entity",
|
||||
"external_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"entity_id": 1,
|
||||
"permalink": "notes/x",
|
||||
"title": "X",
|
||||
@@ -247,6 +248,7 @@ async def test_recent_activity_get_project_activity_timezone_normalization(monke
|
||||
{
|
||||
"primary_result": {
|
||||
"type": "entity",
|
||||
"external_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"entity_id": 2,
|
||||
"permalink": "notes/y",
|
||||
"title": "Y",
|
||||
@@ -341,6 +343,7 @@ def test_recent_activity_format_discovery_output_includes_other_active_projects_
|
||||
results=[
|
||||
ContextResult(
|
||||
primary_result=EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440001",
|
||||
entity_id=1,
|
||||
permalink="docs/complete-feature",
|
||||
title="Complete Feature Spec",
|
||||
@@ -358,6 +361,7 @@ def test_recent_activity_format_discovery_output_includes_other_active_projects_
|
||||
results=[
|
||||
ContextResult(
|
||||
primary_result=EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440002",
|
||||
entity_id=2,
|
||||
permalink="docs/other",
|
||||
title="Other Note",
|
||||
|
||||
@@ -144,23 +144,29 @@ async def test_get_default_project_with_false_values(project_repository: Project
|
||||
causing MultipleResultsFound when multiple projects had different boolean values.
|
||||
"""
|
||||
# Create projects with explicit is_default values
|
||||
project_true = await project_repository.create({
|
||||
"name": "Default Project",
|
||||
"path": "/default/path",
|
||||
"is_default": True,
|
||||
})
|
||||
project_true = await project_repository.create(
|
||||
{
|
||||
"name": "Default Project",
|
||||
"path": "/default/path",
|
||||
"is_default": True,
|
||||
}
|
||||
)
|
||||
|
||||
await project_repository.create({
|
||||
"name": "Not Default Project",
|
||||
"path": "/not-default/path",
|
||||
"is_default": False,
|
||||
})
|
||||
await project_repository.create(
|
||||
{
|
||||
"name": "Not Default Project",
|
||||
"path": "/not-default/path",
|
||||
"is_default": False,
|
||||
}
|
||||
)
|
||||
|
||||
await project_repository.create({
|
||||
"name": "Null Default Project",
|
||||
"path": "/null/path",
|
||||
"is_default": None,
|
||||
})
|
||||
await project_repository.create(
|
||||
{
|
||||
"name": "Null Default Project",
|
||||
"path": "/null/path",
|
||||
"is_default": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Should return only the project with is_default=True
|
||||
default = await project_repository.get_default_project()
|
||||
|
||||
@@ -22,6 +22,7 @@ class TestDateTimeSerialization:
|
||||
test_datetime = datetime(2023, 12, 8, 10, 30, 0)
|
||||
|
||||
entity = EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
entity_id=1,
|
||||
permalink="test/entity",
|
||||
title="Test Entity",
|
||||
@@ -36,6 +37,7 @@ class TestDateTimeSerialization:
|
||||
assert data["created_at"] == "2023-12-08T10:30:00"
|
||||
assert data["type"] == "entity"
|
||||
assert data["title"] == "Test Entity"
|
||||
assert data["external_id"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
def test_relation_summary_datetime_serialization(self):
|
||||
"""Test RelationSummary serializes datetime as ISO format string."""
|
||||
@@ -105,6 +107,7 @@ class TestDateTimeSerialization:
|
||||
test_datetime = datetime(2023, 12, 8, 9, 30, 15)
|
||||
|
||||
entity = EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
entity_id=1,
|
||||
permalink="test/entity",
|
||||
title="Test Entity",
|
||||
@@ -139,6 +142,7 @@ class TestDateTimeSerialization:
|
||||
test_datetime = datetime(2023, 12, 8, 14, 20, 10)
|
||||
|
||||
entity = EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
entity_id=1,
|
||||
permalink="test/entity",
|
||||
title="Test Entity",
|
||||
@@ -168,6 +172,7 @@ class TestDateTimeSerialization:
|
||||
test_datetime = datetime(2023, 12, 8, 10, 30, 0, 123456)
|
||||
|
||||
entity = EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
entity_id=1,
|
||||
permalink="test/entity",
|
||||
title="Test Entity",
|
||||
@@ -186,6 +191,7 @@ class TestDateTimeSerialization:
|
||||
test_datetime = datetime(2023, 12, 8, 10, 30, 0)
|
||||
|
||||
entity = EntitySummary(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
entity_id=1,
|
||||
permalink="test/entity",
|
||||
title="Test Entity",
|
||||
@@ -223,6 +229,7 @@ class TestDateTimeSerialization:
|
||||
|
||||
if model_class == EntitySummary:
|
||||
instance = model_class(
|
||||
external_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
entity_id=1,
|
||||
permalink="test",
|
||||
title="Test",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for EntityService."""
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
@@ -347,6 +348,55 @@ async def test_update_note_entity_content(entity_service: EntityService, file_se
|
||||
assert metadata.get("status") == "draft"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_write_and_reindex_entity(
|
||||
entity_repository: EntityRepository,
|
||||
observation_repository,
|
||||
relation_repository,
|
||||
entity_parser: EntityParser,
|
||||
file_service: FileService,
|
||||
link_resolver,
|
||||
search_service: SearchService,
|
||||
app_config: BasicMemoryConfig,
|
||||
):
|
||||
"""Fast write should defer observations/relations until reindex."""
|
||||
service = EntityService(
|
||||
entity_repository=entity_repository,
|
||||
observation_repository=observation_repository,
|
||||
relation_repository=relation_repository,
|
||||
entity_parser=entity_parser,
|
||||
file_service=file_service,
|
||||
link_resolver=link_resolver,
|
||||
search_service=search_service,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
schema = EntitySchema(
|
||||
title="Reindex Target",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content=dedent("""
|
||||
# Reindex Target
|
||||
|
||||
- [note] Deferred observation
|
||||
- relates_to [[Other Entity]]
|
||||
""").strip(),
|
||||
)
|
||||
external_id = str(uuid.uuid4())
|
||||
fast_entity = await service.fast_write_entity(schema, external_id=external_id)
|
||||
|
||||
assert fast_entity.external_id == external_id
|
||||
assert len(fast_entity.observations) == 0
|
||||
assert len(fast_entity.relations) == 0
|
||||
|
||||
await service.reindex_entity(fast_entity.id)
|
||||
reindexed = await entity_repository.get_by_external_id(external_id)
|
||||
|
||||
assert reindexed is not None
|
||||
assert len(reindexed.observations) == 1
|
||||
assert len(reindexed.relations) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_or_update_new(entity_service: EntityService, file_service: FileService):
|
||||
"""Should create a new entity."""
|
||||
|
||||
@@ -509,8 +509,7 @@ async def test_source_path_same_folder_preference(context_link_resolver):
|
||||
"""Test that links prefer notes in the same folder as the source."""
|
||||
# From main/testing/another-test.md, [[testing]] should find main/testing/testing.md
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="main/testing/another-test.md"
|
||||
"testing", source_path="main/testing/another-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "main/testing/testing.md"
|
||||
@@ -520,10 +519,7 @@ async def test_source_path_same_folder_preference(context_link_resolver):
|
||||
async def test_source_path_from_root_prefers_root(context_link_resolver):
|
||||
"""Test that links from root-level notes prefer root-level matches."""
|
||||
# From root-note.md, [[testing]] should find testing.md (root level)
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="some-root-note.md"
|
||||
)
|
||||
result = await context_link_resolver.resolve_link("testing", source_path="some-root-note.md")
|
||||
assert result is not None
|
||||
assert result.file_path == "testing.md"
|
||||
|
||||
@@ -534,10 +530,7 @@ async def test_source_path_different_branch_prefers_closest(context_link_resolve
|
||||
# From other/testing.md, [[testing]] should find other/testing.md (same folder)
|
||||
# Wait, other/testing.md IS the testing note in that folder, so this tests self-reference
|
||||
# Let's test from a hypothetical other/different.md
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="other/different.md"
|
||||
)
|
||||
result = await context_link_resolver.resolve_link("testing", source_path="other/different.md")
|
||||
assert result is not None
|
||||
# Should find other/testing.md since it's in the same folder
|
||||
assert result.file_path == "other/testing.md"
|
||||
@@ -556,8 +549,7 @@ async def test_source_path_ancestor_preference(context_link_resolver):
|
||||
# 1. deep/nested/folder/note.md (same folder) - but that's the note itself
|
||||
# Let's say we're linking from a different file in that folder
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"note",
|
||||
source_path="deep/nested/folder/other-file.md"
|
||||
"note", source_path="deep/nested/folder/other-file.md"
|
||||
)
|
||||
assert result is not None
|
||||
# Should find deep/nested/folder/note.md (same folder)
|
||||
@@ -573,8 +565,7 @@ async def test_source_path_parent_folder_preference(context_link_resolver):
|
||||
# For this test, let's check that from deep/nested/other/file.md,
|
||||
# [[note]] finds deep/note.md (ancestor) rather than note.md (root)
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"note",
|
||||
source_path="deep/nested/other/file.md"
|
||||
"note", source_path="deep/nested/other/file.md"
|
||||
)
|
||||
assert result is not None
|
||||
# No note.md in deep/nested/other/, so should find deep/note.md (closest ancestor)
|
||||
@@ -602,7 +593,7 @@ async def test_source_path_unique_title_ignores_context(context_link_resolver):
|
||||
# "another-test" only exists in one place
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"another-test",
|
||||
source_path="other/some-file.md" # Different folder
|
||||
source_path="other/some-file.md", # Different folder
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "main/testing/another-test.md"
|
||||
@@ -617,8 +608,7 @@ async def test_source_path_with_permalink_conflict(context_link_resolver):
|
||||
# even though there's a permalink match at root
|
||||
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="main/testing/another-test.md"
|
||||
"testing", source_path="main/testing/another-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
# Should prefer same-folder title match over root permalink match
|
||||
@@ -633,24 +623,22 @@ async def test_find_closest_entity_same_folder(context_link_resolver, context_aw
|
||||
assert len(testing_entities) == 3 # root, main/testing, other
|
||||
|
||||
closest = context_link_resolver._find_closest_entity(
|
||||
testing_entities,
|
||||
"main/testing/another-test.md"
|
||||
testing_entities, "main/testing/another-test.md"
|
||||
)
|
||||
assert closest.file_path == "main/testing/testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_closest_entity_ancestor_preference(context_link_resolver, context_aware_entities):
|
||||
async def test_find_closest_entity_ancestor_preference(
|
||||
context_link_resolver, context_aware_entities
|
||||
):
|
||||
"""Test _find_closest_entity prefers closer ancestors."""
|
||||
# Get entities with title "note"
|
||||
note_entities = [e for e in context_aware_entities if e.title == "note"]
|
||||
assert len(note_entities) == 3 # deep/nested/folder, deep, root
|
||||
|
||||
# From deep/nested/other/file.md, should prefer deep/note.md over note.md
|
||||
closest = context_link_resolver._find_closest_entity(
|
||||
note_entities,
|
||||
"deep/nested/other/file.md"
|
||||
)
|
||||
closest = context_link_resolver._find_closest_entity(note_entities, "deep/nested/other/file.md")
|
||||
assert closest.file_path == "deep/note.md"
|
||||
|
||||
|
||||
@@ -660,10 +648,7 @@ async def test_find_closest_entity_root_source(context_link_resolver, context_aw
|
||||
testing_entities = [e for e in context_aware_entities if e.title == "testing"]
|
||||
|
||||
# From root level, should prefer root testing.md
|
||||
closest = context_link_resolver._find_closest_entity(
|
||||
testing_entities,
|
||||
"some-root-file.md"
|
||||
)
|
||||
closest = context_link_resolver._find_closest_entity(testing_entities, "some-root-file.md")
|
||||
assert closest.file_path == "testing.md"
|
||||
|
||||
|
||||
@@ -671,8 +656,7 @@ async def test_find_closest_entity_root_source(context_link_resolver, context_aw
|
||||
async def test_nonexistent_link_with_source_path(context_link_resolver):
|
||||
"""Test that non-existent links return None even with source_path."""
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"does-not-exist",
|
||||
source_path="main/testing/another-test.md"
|
||||
"does-not-exist", source_path="main/testing/another-test.md"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@@ -774,8 +758,7 @@ async def test_relative_path_resolution_from_subfolder(relative_path_resolver):
|
||||
"""Test that [[nested/deep-note]] from testing/link-test.md resolves to testing/nested/deep-note.md."""
|
||||
# From testing/link-test.md, [[nested/deep-note]] should resolve to testing/nested/deep-note.md
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"nested/deep-note",
|
||||
source_path="testing/link-test.md"
|
||||
"nested/deep-note", source_path="testing/link-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "testing/nested/deep-note.md"
|
||||
@@ -787,8 +770,7 @@ async def test_relative_path_falls_back_to_absolute(relative_path_resolver):
|
||||
# From other/file.md, [[nested/deep-note]] should resolve to nested/deep-note.md (absolute)
|
||||
# because other/nested/deep-note.md doesn't exist
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"nested/deep-note",
|
||||
source_path="other/file.md"
|
||||
"nested/deep-note", source_path="other/file.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "nested/deep-note.md"
|
||||
@@ -808,8 +790,7 @@ async def test_relative_path_from_root_falls_through(relative_path_resolver):
|
||||
"""Test that paths from root-level files don't try relative resolution."""
|
||||
# From root-file.md (no folder), [[nested/deep-note]] should resolve to nested/deep-note.md
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"nested/deep-note",
|
||||
source_path="root-file.md"
|
||||
"nested/deep-note", source_path="root-file.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "nested/deep-note.md"
|
||||
@@ -820,8 +801,7 @@ async def test_simple_link_no_slash_skips_relative_resolution(relative_path_reso
|
||||
"""Test that links without '/' don't trigger relative path resolution."""
|
||||
# [[deep-note]] should use context-aware title matching, not relative paths
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"deep-note",
|
||||
source_path="testing/link-test.md"
|
||||
"deep-note", source_path="testing/link-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
# Should find testing/nested/deep-note.md via title match with same-folder preference
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test general sync behavior."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
@@ -836,8 +835,8 @@ Modified content for timestamp test
|
||||
entity_updated_epoch = entity_after.updated_at.timestamp()
|
||||
file_mtime = file_stats_after_modification.st_mtime
|
||||
|
||||
# Allow 2s difference on Windows due to filesystem timing precision
|
||||
tolerance = 2 if os.name == "nt" else 1
|
||||
# Allow 2s difference due to filesystem timing precision and sync processing delays
|
||||
tolerance = 2
|
||||
assert abs(entity_updated_epoch - file_mtime) < tolerance, (
|
||||
f"Entity updated_at ({entity_after.updated_at}) should match file mtime "
|
||||
f"({datetime.fromtimestamp(file_mtime)}) within {tolerance}s tolerance"
|
||||
|
||||
Reference in New Issue
Block a user