Skip to content

Commit 7f2d4d2

Browse files
phernandezclaude
andcommitted
fix: three cloud-testing bugs (#640, #641, #642)
🔧 #640 — LinkResolver selects worst match instead of best Replace `min(results, key=lambda x: x.score)` with `results[0]`. Both SQLite and Postgres return results sorted best-first in SQL, so using `results[0]` is backend-agnostic and correct. 🔧 #641 — search_notes output_format="text" returns raw Pydantic model Add `_format_search_markdown()` that formats SearchResponse as readable markdown with title, permalink, score, and matched snippet per result. Update prompts to use `output_format="json"` since they need structured data for result counting and branching logic. 🔧 #642 — metadata_filters with `note_type` key returns empty results Add `_METADATA_KEY_ALIASES` mapping at the tool level that aliases `note_type` → `type` before passing metadata_filters to the search query. The frontmatter field is `type`, not `note_type`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 8b7f39e commit 7f2d4d2

9 files changed

Lines changed: 431 additions & 142 deletions

File tree

src/basic_memory/mcp/prompts/continue_conversation.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from basic_memory.mcp.server import mcp
1414
from basic_memory.mcp.tools.recent_activity import recent_activity
1515
from basic_memory.mcp.tools.search import search_notes
16-
from basic_memory.schemas.search import SearchResponse
1716

1817

1918
@mcp.prompt(
@@ -42,15 +41,12 @@ async def continue_conversation(
4241
logger.info(f"Continuing session, topic: {topic}, timeframe: {timeframe}")
4342

4443
if topic:
45-
# Search for the topic using the search tool directly
46-
result = await search_notes(query=topic, after_date=timeframe)
44+
# Use json format to get structured data for result counting and branching
45+
result = await search_notes(query=topic, after_date=timeframe, output_format="json")
4746

48-
if isinstance(result, SearchResponse):
49-
context_text = _format_continuation_results(result, topic)
50-
result_count = len(result.results)
51-
elif isinstance(result, dict):
47+
if isinstance(result, dict):
5248
results = result.get("results", [])
53-
context_text = str(result)
49+
context_text = _format_continuation_results(results, topic)
5450
result_count = len(results)
5551
else:
5652
# Error string
@@ -111,23 +107,24 @@ async def continue_conversation(
111107
return prompt
112108

113109

114-
def _format_continuation_results(result: SearchResponse, topic: str) -> str:
115-
"""Format search results for conversation continuation context."""
116-
if not result.results:
110+
def _format_continuation_results(results: list[dict], topic: str) -> str:
111+
"""Format search result dicts for conversation continuation context."""
112+
if not results:
117113
return f"No previous context found for '{topic}'."
118114

119115
lines = [f"## Previous Context for '{topic}'\n"]
120116

121-
for item in result.results:
122-
title = item.title or "Untitled"
123-
permalink = item.permalink or ""
117+
for item in results:
118+
title = item.get("title", "Untitled")
119+
permalink = item.get("permalink", "")
124120

125121
lines.append(f"### {title}")
126122
if permalink:
127123
lines.append(f"permalink: {permalink}")
128124
lines.append(f'Read with: `read_note("{permalink}")`')
129-
if item.content:
130-
content = item.content[:300] + "..." if len(item.content) > 300 else item.content
125+
content = item.get("content")
126+
if content:
127+
content = content[:300] + "..." if len(content) > 300 else content
131128
lines.append(f"\n{content}")
132129
lines.append("")
133130

src/basic_memory/mcp/prompts/search.py

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
from basic_memory.mcp.server import mcp
1313
from basic_memory.mcp.tools.search import search_notes
14-
from basic_memory.schemas.search import SearchResponse
1514

1615

1716
@mcp.prompt(
@@ -39,18 +38,14 @@ async def search_prompt(
3938
"""
4039
logger.info(f"Searching knowledge base, query: {query}, timeframe: {timeframe}")
4140

42-
# Call the search tool directly — it returns SearchResponse, dict, or error string
43-
result = await search_notes(query=query, after_date=timeframe)
41+
# Use json format to get structured data for result counting and formatting
42+
result = await search_notes(query=query, after_date=timeframe, output_format="json")
4443

4544
# Format the tool output into a prompt with guidance
46-
if isinstance(result, SearchResponse):
47-
result_count = len(result.results)
48-
result_text = _format_search_results(result, query)
49-
elif isinstance(result, dict):
50-
# json output format
45+
if isinstance(result, dict):
5146
results = result.get("results", [])
5247
result_count = len(results)
53-
result_text = str(result)
48+
result_text = _format_search_results(results, query)
5449
else:
5550
# Error string from search tool
5651
result_count = 0
@@ -76,28 +71,27 @@ async def search_prompt(
7671
""")
7772

7873

79-
def _format_search_results(result: SearchResponse, query: str) -> str:
80-
"""Format SearchResponse into readable markdown."""
81-
if not result.results:
74+
def _format_search_results(results: list[dict], query: str) -> str:
75+
"""Format search result dicts into readable markdown."""
76+
if not results:
8277
return f"No results found for '{query}'."
8378

84-
lines = [f"Found {len(result.results)} results:\n"]
79+
lines = [f"Found {len(results)} results:\n"]
8580

86-
for item in result.results:
87-
title = item.title or "Untitled"
88-
permalink = item.permalink or ""
89-
score = f" (score: {item.score:.2f})" if item.score else ""
81+
for item in results:
82+
title = item.get("title", "Untitled")
83+
permalink = item.get("permalink", "")
84+
score = item.get("score")
85+
score_text = f" (score: {score:.2f})" if score else ""
9086

91-
lines.append(f"- **{title}**{score}")
87+
lines.append(f"- **{title}**{score_text}")
9288
if permalink:
9389
lines.append(f" permalink: {permalink}")
94-
if item.content:
90+
content = item.get("content")
91+
if content:
9592
# Truncate content snippet
96-
content = item.content[:200] + "..." if len(item.content) > 200 else item.content
93+
content = content[:200] + "..." if len(content) > 200 else content
9794
lines.append(f" {content}")
9895
lines.append("")
9996

100-
if result.has_more:
101-
lines.append("*More results available. Use page=2 to see next page.*")
102-
10397
return "\n".join(lines)

src/basic_memory/mcp/tools/search.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,46 @@ def _format_search_error_response(
251251
- **Patterns**: `tag:example`, `category:observation`"""
252252

253253

254+
def _format_search_markdown(result: SearchResponse, project: str, query: str | None) -> str:
255+
"""Format SearchResponse as compact markdown text.
256+
257+
Produces a human-readable markdown representation suitable for LLM
258+
consumption when structured data isn't needed.
259+
"""
260+
if not result.results:
261+
return f"No results found for '{query or ''}' in project '{project}'."
262+
263+
parts = []
264+
265+
# --- Header ---
266+
if query:
267+
parts.append(f"# Search Results: {query}")
268+
else:
269+
parts.append("# Search Results")
270+
parts.append(f"*project: {project}*")
271+
parts.append("")
272+
273+
# --- Result blocks ---
274+
for r in result.results:
275+
parts.append(f"### {r.title}")
276+
parts.append(f"- permalink: {r.permalink}")
277+
parts.append(f"- score: {r.score:.4f}")
278+
if r.matched_chunk:
279+
parts.append(f"- match: {r.matched_chunk[:200]}")
280+
parts.append("")
281+
282+
# --- Footer with pagination ---
283+
parts.append("---")
284+
count = len(result.results)
285+
parts.append(
286+
f"*{count} result{'s' if count != 1 else ''}"
287+
f" | page {result.current_page}, page_size {result.page_size}"
288+
f"{' | more available' if result.has_more else ''}*"
289+
)
290+
291+
return "\n".join(parts)
292+
293+
254294
@mcp.tool(
255295
description="Search across all content in the knowledge base with advanced syntax support.",
256296
# TODO: re-enable once MCP client rendering is working
@@ -273,7 +313,7 @@ async def search_notes(
273313
status: Optional[str] = None,
274314
min_similarity: Optional[float] = None,
275315
context: Context | None = None,
276-
) -> SearchResponse | dict | str:
316+
) -> dict | str:
277317
"""Search across all content in the knowledge base with comprehensive syntax support.
278318
279319
This tool searches the knowledge base using full-text search, pattern matching,
@@ -373,7 +413,8 @@ async def search_notes(
373413
context: Optional FastMCP context for performance caching.
374414
375415
Returns:
376-
SearchResponse with results and pagination info, or helpful error guidance if search fails
416+
Formatted markdown text (output_format="text"), dict (output_format="json"),
417+
or helpful error guidance string if search fails
377418
378419
Examples:
379420
# Basic text search
@@ -519,6 +560,13 @@ async def search_notes(
519560
if after_date:
520561
search_query.after_date = after_date
521562
if metadata_filters:
563+
# Alias common column/model names to their frontmatter key equivalents.
564+
# Users often pass "note_type" (the entity model column) when the
565+
# frontmatter field is actually "type".
566+
_METADATA_KEY_ALIASES = {"note_type": "type"}
567+
metadata_filters = {
568+
_METADATA_KEY_ALIASES.get(k, k): v for k, v in metadata_filters.items()
569+
}
522570
search_query.metadata_filters = metadata_filters
523571
if tags:
524572
search_query.tags = tags
@@ -565,7 +613,7 @@ async def search_notes(
565613
if output_format == "json":
566614
return result.model_dump(mode="json", exclude_none=True)
567615

568-
return result
616+
return _format_search_markdown(result, active_project.name, query)
569617

570618
except Exception as e:
571619
logger.error(

src/basic_memory/services/link_resolver.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,10 @@ async def _resolve_in_project(
301301
)
302302

303303
if results:
304-
# Look for best match
305-
best_match = min(results, key=lambda x: x.score) # pyright: ignore
304+
# Both SQLite and Postgres return results sorted best-first in SQL
305+
# (SQLite: ORDER BY score ASC for negative BM25, Postgres: ORDER BY score DESC
306+
# for positive ts_rank). Using results[0] is backend-agnostic and correct.
307+
best_match = results[0]
306308
logger.trace(
307309
f"Selected best match from {len(results)} results: {best_match.permalink}"
308310
)

test-int/mcp/test_delete_note_integration.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,8 @@ async def test_delete_note_by_permalink(mcp_server, app, test_project):
105105
},
106106
)
107107

108-
# Should have no results
109-
assert (
110-
'"results": []' in search_result.content[0].text
111-
or '"results":[]' in search_result.content[0].text
112-
)
108+
# Default text format returns "No results found" when empty
109+
assert "No results found" in search_result.content[0].text
113110

114111

115112
@pytest.mark.asyncio
@@ -387,11 +384,8 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project)
387384
},
388385
)
389386

390-
# Should have no results
391-
assert (
392-
'"results": []' in search_result.content[0].text
393-
or '"results":[]' in search_result.content[0].text
394-
)
387+
# Default text format returns "No results found" when empty
388+
assert "No results found" in search_result.content[0].text
395389

396390

397391
@pytest.mark.asyncio

test-int/mcp/test_search_integration.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,9 @@ async def test_search_pagination(mcp_server, app, test_project):
362362
)
363363

364364
result_text = search_result.content[0].text
365-
# Should contain 5 results and pagination info
366-
assert '"current_page":1' in result_text
367-
assert '"page_size":5' in result_text
365+
# Text format includes pagination info in footer
366+
assert "page 1" in result_text
367+
assert "page_size 5" in result_text
368368

369369
# Search page 2
370370
search_result = await client.call_tool(
@@ -378,7 +378,7 @@ async def test_search_pagination(mcp_server, app, test_project):
378378
)
379379

380380
result_text = search_result.content[0].text
381-
assert '"current_page":2' in result_text
381+
assert "page 2" in result_text
382382

383383

384384
@pytest.mark.asyncio
@@ -407,8 +407,9 @@ async def test_search_no_results(mcp_server, app, test_project):
407407
},
408408
)
409409

410+
# Default text format returns "No results found" when empty
410411
result_text = search_result.content[0].text
411-
assert '"results": []' in result_text or '"results":[]' in result_text
412+
assert "No results found" in result_text
412413

413414

414415
@pytest.mark.asyncio

0 commit comments

Comments
 (0)