Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/basic_memory/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.exception_handlers import http_exception_handler
from fastapi.routing import APIRouter
from loguru import logger
Expand All @@ -21,6 +21,7 @@
)
from basic_memory.api.v2.routers.project_router import list_projects
from basic_memory.config import init_api_logging
from basic_memory.services.exceptions import EntityAlreadyExistsError
from basic_memory.services.initialization import initialize_app


Expand Down Expand Up @@ -93,6 +94,31 @@ async def lifespan(app: FastAPI): # pragma: no cover
# V2 routers are the only public API surface


@app.exception_handler(EntityAlreadyExistsError)
async def entity_already_exists_error_handler(
request: Request, exc: EntityAlreadyExistsError
):
"""Handle entity creation conflicts (e.g., file already exists).

This is expected behavior when users try to create notes that exist,
so log at INFO level instead of ERROR.
"""
logger.info(
"Entity already exists",
url=str(request.url),
method=request.method,
path=request.url.path,
error=str(exc),
)
return await http_exception_handler(
request,
HTTPException(
status_code=409,
detail="Note already exists. Use edit_note to modify it, or delete it first.",
),
)


@app.exception_handler(Exception)
async def exception_handler(request, exc): # pragma: no cover
logger.exception(
Expand Down
10 changes: 7 additions & 3 deletions src/basic_memory/services/entity_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@
DirectoryDeleteError,
)
from basic_memory.services import BaseService, FileService
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
from basic_memory.services.exceptions import (
EntityAlreadyExistsError,
EntityCreationError,
EntityNotFoundError,
)
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.utils import generate_permalink
Expand Down Expand Up @@ -216,7 +220,7 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel:
file_path = Path(schema.file_path)

if await self.file_service.exists(file_path):
raise EntityCreationError(
raise EntityAlreadyExistsError(
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
)

Expand Down Expand Up @@ -356,7 +360,7 @@ async def fast_write_entity(
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(
raise EntityAlreadyExistsError(
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
)

Expand Down
6 changes: 6 additions & 0 deletions src/basic_memory/services/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ class EntityCreationError(Exception):
pass


class EntityAlreadyExistsError(EntityCreationError):
"""Raised when an entity file already exists"""

pass


class DirectoryOperationError(Exception):
"""Raised when directory operations fail"""

Expand Down
28 changes: 28 additions & 0 deletions tests/api/v2/test_knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,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_conflict_returns_409(client: AsyncClient, v2_project_url):
"""Test creating a duplicate entity returns 409 Conflict."""
data = {
"title": "TestV2EntityConflict",
"directory": "conflict",
"entity_type": "note",
"content_type": "text/markdown",
"content": "Original content for conflict",
}

response = await client.post(
f"{v2_project_url}/knowledge/entities",
json=data,
params={"fast": False},
)
assert response.status_code == 200

response = await client.post(
f"{v2_project_url}/knowledge/entities",
json=data,
params={"fast": False},
)
assert response.status_code == 409
expected_detail = "Note already exists. Use edit_note to modify it, or delete it first."
assert response.json()["detail"] == expected_detail


@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."""
Expand Down
18 changes: 9 additions & 9 deletions tests/services/test_search_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,9 @@ async def test_boolean_not_search(search_service, test_graph):

# Should find "Root Entity" but not "Connected Entity"
for result in results:
assert (
"connected" not in result.permalink.lower()
), "Boolean NOT search returned excluded term"
assert "connected" not in result.permalink.lower(), (
"Boolean NOT search returned excluded term"
)


@pytest.mark.asyncio
Expand All @@ -366,9 +366,9 @@ async def test_boolean_group_search(search_service, test_graph):
"root" in result.title.lower() or "connected" in result.title.lower()
)

assert (
contains_entity and contains_root_or_connected
), "Boolean grouped search returned incorrect results"
assert contains_entity and contains_root_or_connected, (
"Boolean grouped search returned incorrect results"
)


@pytest.mark.asyncio
Expand Down Expand Up @@ -398,9 +398,9 @@ async def test_boolean_operators_detection(search_service):

for query_text in non_boolean_queries:
query = SearchQuery(text=query_text)
assert (
not query.has_boolean_operators()
), f"Incorrectly detected boolean operators in: {query_text}"
assert not query.has_boolean_operators(), (
f"Incorrectly detected boolean operators in: {query_text}"
)


# Tests for frontmatter tag search functionality
Expand Down
Loading