Skip to content

Commit 69a625a

Browse files
committed
fix search escape issues, and empty forward reference resolving for entities
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 53c29a3 commit 69a625a

4 files changed

Lines changed: 76 additions & 22 deletions

File tree

src/basic_memory/api/routers/knowledge_router.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
FileServiceDep,
1515
ProjectConfigDep,
1616
AppConfigDep,
17+
SyncServiceDep,
1718
)
1819
from basic_memory.schemas import (
1920
EntityListResponse,
@@ -63,6 +64,7 @@ async def create_or_update_entity(
6364
entity_service: EntityServiceDep,
6465
search_service: SearchServiceDep,
6566
file_service: FileServiceDep,
67+
sync_service: SyncServiceDep,
6668
) -> EntityResponse:
6769
"""Create or update an entity. If entity exists, it will be updated, otherwise created."""
6870
logger.info(
@@ -85,6 +87,17 @@ async def create_or_update_entity(
8587

8688
# reindex
8789
await search_service.index_entity(entity, background_tasks=background_tasks)
90+
91+
# Attempt immediate relation resolution when creating new entities
92+
# This helps resolve forward references when related entities are created in the same session
93+
if created:
94+
try:
95+
await sync_service.resolve_relations()
96+
logger.debug(f"Resolved relations after creating entity: {entity.permalink}")
97+
except Exception as e:
98+
# Don't fail the entire request if relation resolution fails
99+
logger.warning(f"Failed to resolve relations after entity creation: {e}")
100+
88101
result = EntityResponse.model_validate(entity)
89102

90103
logger.info(

src/basic_memory/mcp/tools/write_note.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ async def write_note(
120120
summary.append(f"- Resolved: {resolved}")
121121
if unresolved:
122122
summary.append(f"- Unresolved: {unresolved}")
123-
summary.append("\nUnresolved relations will be retried on next sync.")
123+
summary.append("\nNote: Unresolved relations point to entities that don't exist yet.")
124+
summary.append(
125+
"They will be automatically resolved when target entities are created or during sync operations."
126+
)
124127

125128
if tag_list:
126129
summary.append(f"\n## Tags\n- {', '.join(tag_list)}")

src/basic_memory/repository/search_repository.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,15 +144,36 @@ def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
144144

145145
# Characters that can cause FTS5 syntax errors when used as operators
146146
# We're more conservative here - only quote when we detect problematic patterns
147-
problematic_chars = ['"', "'", "(", ")", "[", "]", "+", "!", "@", "#", "$", "%", "^", "&", "=", "|", "\\", "~", "`"]
148-
147+
problematic_chars = [
148+
'"',
149+
"'",
150+
"(",
151+
")",
152+
"[",
153+
"]",
154+
"+",
155+
"!",
156+
"@",
157+
"#",
158+
"$",
159+
"%",
160+
"^",
161+
"&",
162+
"=",
163+
"|",
164+
"\\",
165+
"~",
166+
"`",
167+
]
168+
149169
# Characters that indicate we should quote (spaces, dots, colons, etc.)
150-
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/"]
151-
170+
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
171+
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
172+
152173
# Check if term needs quoting
153174
has_problematic = any(c in term for c in problematic_chars)
154175
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
155-
176+
156177
if has_problematic or has_spaces_or_special:
157178
# Escape any existing quotes by doubling them
158179
escaped_term = term.replace('"', '""')
@@ -215,15 +236,21 @@ async def search(
215236

216237
# Handle permalink match search, supports *
217238
if permalink_match:
218-
# Clean and prepare permalink for FTS5 GLOB match
219-
permalink_text = self._prepare_search_term(
220-
permalink_match.lower().strip(), is_prefix=False
221-
)
239+
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
240+
# GLOB patterns need to preserve their syntax
241+
permalink_text = permalink_match.lower().strip()
222242
params["permalink"] = permalink_text
223243
if "*" in permalink_match:
224244
conditions.append("permalink GLOB :permalink")
225245
else:
226-
conditions.append("permalink MATCH :permalink")
246+
# For exact matches without *, we can use FTS5 MATCH
247+
# but only prepare the term if it doesn't look like a path
248+
if "/" in permalink_text:
249+
conditions.append("permalink = :permalink")
250+
else:
251+
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
252+
params["permalink"] = permalink_text
253+
conditions.append("permalink MATCH :permalink")
227254

228255
# Handle entity type filter
229256
if search_item_types:

tests/repository/test_search_repository.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,14 @@ def test_terms_with_existing_wildcard_unchanged(self, search_repository):
320320
def test_boolean_operators_preserved(self, search_repository):
321321
"""Boolean operators should be preserved without modification."""
322322
assert search_repository._prepare_search_term("hello AND world") == "hello AND world"
323-
assert search_repository._prepare_search_term("cat OR dog") == "cat OR dog"
324-
assert search_repository._prepare_search_term("project NOT meeting") == "project NOT meeting"
325-
assert search_repository._prepare_search_term("(hello AND world) OR test") == "(hello AND world) OR test"
323+
assert search_repository._prepare_search_term("cat OR dog") == "cat OR dog"
324+
assert (
325+
search_repository._prepare_search_term("project NOT meeting") == "project NOT meeting"
326+
)
327+
assert (
328+
search_repository._prepare_search_term("(hello AND world) OR test")
329+
== "(hello AND world) OR test"
330+
)
326331

327332
def test_programming_terms_should_work(self, search_repository):
328333
"""Programming-related terms with special chars should be searchable."""
@@ -347,8 +352,14 @@ def test_quoted_strings_handled_properly(self, search_repository):
347352

348353
def test_file_paths_no_prefix_wildcard(self, search_repository):
349354
"""File paths should not get prefix wildcards."""
350-
assert search_repository._prepare_search_term("config.json", is_prefix=False) == '"config.json"'
351-
assert search_repository._prepare_search_term("docs/readme.md", is_prefix=False) == '"docs/readme.md"'
355+
assert (
356+
search_repository._prepare_search_term("config.json", is_prefix=False)
357+
== '"config.json"'
358+
)
359+
assert (
360+
search_repository._prepare_search_term("docs/readme.md", is_prefix=False)
361+
== '"docs/readme.md"'
362+
)
352363

353364
def test_spaces_handled_correctly(self, search_repository):
354365
"""Terms with spaces should be quoted."""
@@ -359,17 +370,17 @@ def test_spaces_handled_correctly(self, search_repository):
359370
async def test_search_with_special_characters_returns_results(self, search_repository):
360371
"""Integration test: search with special characters should work gracefully."""
361372
# This test ensures the search doesn't crash with FTS5 syntax errors
362-
373+
363374
# These should all return empty results gracefully, not crash
364375
results1 = await search_repository.search(search_text="C++")
365376
assert isinstance(results1, list) # Should not crash
366-
377+
367378
results2 = await search_repository.search(search_text="function()")
368379
assert isinstance(results2, list) # Should not crash
369-
380+
370381
results3 = await search_repository.search(search_text="+++malformed+++")
371382
assert isinstance(results3, list) # Should not crash, return empty results
372-
383+
373384
results4 = await search_repository.search(search_text="email@domain.com")
374385
assert isinstance(results4, list) # Should not crash
375386

@@ -379,9 +390,9 @@ async def test_boolean_search_still_works(self, search_repository):
379390
# These should not crash and should respect boolean logic
380391
results1 = await search_repository.search(search_text="hello AND world")
381392
assert isinstance(results1, list)
382-
393+
383394
results2 = await search_repository.search(search_text="cat OR dog")
384395
assert isinstance(results2, list)
385-
396+
386397
results3 = await search_repository.search(search_text="project NOT meeting")
387398
assert isinstance(results3, list)

0 commit comments

Comments
 (0)