mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix(cli): fix build-context Rich formatter to use real nested ContextResult shape
_display_build_context was reading top-level item.get("title")/item.get("type")
on each results[i], but the real GraphContext.model_dump() shape wraps every item
as a ContextResult with primary_result + related_results nested inside. This
made every related note render as an empty tree node.
Fix:
- Rewrite _display_build_context to iterate context_items, build a primary-result
node from item["primary_result"], then add each item["related_results"] entry as
a child with relation_type/type/title rendered.
- Update _display_search_results to use the real SearchResponse key "current_page"
(not "page"), pass query from the CLI argument, add Score and Snippet columns
(score + matched_chunk/content truncated to 200 chars) as the issue requested.
- Update test fixtures in test_cli_tool_rich_output.py to match the real payload
shapes (nested ContextResult, current_page, score/matched_chunk fields, no
updated_at in recent-activity).
- Fix test_build_context_json_flag_overrides_tty assertion to navigate the nested
primary_result/related_results shape.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
@@ -67,15 +67,23 @@ def _print_json(result: Any) -> None:
|
||||
# --- Rich formatters ---
|
||||
|
||||
|
||||
def _display_search_results(result: dict[str, Any]) -> None:
|
||||
"""Render search-notes results as a Rich table."""
|
||||
def _display_search_results(result: dict[str, Any], query: str = "") -> None:
|
||||
"""Render search-notes results as a Rich table.
|
||||
|
||||
Real SearchResponse.model_dump() shape:
|
||||
results: list of SearchResult dicts (title, type, permalink, score, matched_chunk, content)
|
||||
current_page: int (NOT "page")
|
||||
page_size: int
|
||||
total: int
|
||||
has_more: bool
|
||||
"""
|
||||
results = result.get("results", [])
|
||||
total = result.get("total", len(results))
|
||||
query = result.get("query") or ""
|
||||
page = result.get("page", 1)
|
||||
page_size = result.get("page_size", len(results))
|
||||
# Real key is "current_page"; fall back to "page" for forward-compat.
|
||||
page = result.get("current_page") or result.get("page", 1)
|
||||
page_size = result.get("page_size", len(results)) or 1
|
||||
|
||||
title = f"Search results for [bold cyan]{query}[/bold cyan]" if query else "Search results"
|
||||
title = f"Search: [bold cyan]{query}[/bold cyan]" if query else "Search results"
|
||||
subtitle = f"{total} result(s) • page {page} of {max(1, -(-total // page_size))}"
|
||||
|
||||
if not results:
|
||||
@@ -85,13 +93,21 @@ def _display_search_results(result: dict[str, Any]) -> None:
|
||||
table = Table(show_header=True, header_style="bold", expand=False)
|
||||
table.add_column("Type", style="dim", width=12)
|
||||
table.add_column("Title", style="bold cyan")
|
||||
table.add_column("Score", style="yellow", width=7)
|
||||
table.add_column("Permalink", style="green")
|
||||
table.add_column("Snippet", style="dim", max_width=60)
|
||||
|
||||
for item in results:
|
||||
item_type = item.get("type", "")
|
||||
item_title = item.get("title") or item.get("permalink", "")
|
||||
permalink = item.get("permalink", "")
|
||||
table.add_row(item_type, item_title, permalink)
|
||||
score = item.get("score")
|
||||
score_str = f"{score:.2f}" if score is not None else ""
|
||||
# Prefer matched_chunk as the most relevant snippet; fall back to content.
|
||||
raw_snippet = item.get("matched_chunk") or item.get("content") or ""
|
||||
# Truncate to ~200 chars so the table stays readable.
|
||||
snippet = raw_snippet[:200].replace("\n", " ") if raw_snippet else ""
|
||||
table.add_row(item_type, item_title, score_str, permalink, snippet)
|
||||
|
||||
console.print(Panel(table, title=title, subtitle=subtitle, expand=False))
|
||||
|
||||
@@ -116,33 +132,59 @@ def _display_read_note(result: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _display_build_context(result: dict[str, Any]) -> None:
|
||||
"""Render build-context result as a Rich tree."""
|
||||
"""Render build-context result as a Rich tree.
|
||||
|
||||
Real GraphContext.model_dump() shape:
|
||||
results: list of ContextResult dicts, each with:
|
||||
primary_result: EntitySummary | RelationSummary | ObservationSummary
|
||||
observations: list of ObservationSummary
|
||||
related_results: list of EntitySummary | RelationSummary | ObservationSummary
|
||||
metadata: {"uri": ..., ...}
|
||||
page/page_size/has_more
|
||||
|
||||
Each summary has: type, title (EntitySummary/RelationSummary), permalink,
|
||||
and relation_type (RelationSummary only).
|
||||
"""
|
||||
metadata = result.get("metadata", {})
|
||||
uri = metadata.get("uri", "")
|
||||
results = result.get("results", [])
|
||||
total = len(results)
|
||||
context_items: list[dict[str, Any]] = list(result.get("results", []))
|
||||
|
||||
label = f"[bold cyan]{uri}[/bold cyan]" if uri else "Context"
|
||||
tree = Tree(f"[bold]Context:[/bold] {label}")
|
||||
|
||||
if not results:
|
||||
if not context_items:
|
||||
tree.add("[dim]No related content found.[/dim]")
|
||||
else:
|
||||
for item in results:
|
||||
item_title = item.get("title") or item.get("permalink", "")
|
||||
relation = item.get("relation_type", "")
|
||||
item_type = item.get("type", "")
|
||||
for context_result in context_items:
|
||||
# --- Primary result node ---
|
||||
primary = context_result.get("primary_result", {})
|
||||
p_title = primary.get("title") or primary.get("permalink", "")
|
||||
p_type = primary.get("type", "")
|
||||
primary_label = f"[cyan]{p_title}[/cyan]"
|
||||
if p_type:
|
||||
primary_label = f"[dim]{p_type}[/dim] {primary_label}"
|
||||
primary_node = tree.add(primary_label)
|
||||
|
||||
parts = []
|
||||
if relation:
|
||||
parts.append(f"[yellow]{relation}[/yellow]")
|
||||
if item_type:
|
||||
parts.append(f"[dim]{item_type}[/dim]")
|
||||
parts.append(f"[cyan]{item_title}[/cyan]")
|
||||
# --- Related items as children ---
|
||||
related: list[dict[str, Any]] = list(context_result.get("related_results", []))
|
||||
for rel_item in related:
|
||||
rel_title = rel_item.get("title") or rel_item.get("permalink", "")
|
||||
rel_type = rel_item.get("type", "")
|
||||
relation = rel_item.get("relation_type", "")
|
||||
|
||||
tree.add(" ".join(parts))
|
||||
parts = []
|
||||
if relation:
|
||||
parts.append(f"[yellow]{relation}[/yellow]")
|
||||
if rel_type:
|
||||
parts.append(f"[dim]{rel_type}[/dim]")
|
||||
parts.append(f"[cyan]{rel_title}[/cyan]")
|
||||
primary_node.add(" ".join(parts))
|
||||
|
||||
subtitle = f"{total} related item(s)"
|
||||
# Count total related items across all primary results.
|
||||
total_related = sum(
|
||||
len(cr.get("related_results", [])) for cr in context_items
|
||||
)
|
||||
subtitle = f"{len(context_items)} primary • {total_related} related"
|
||||
console.print(Panel(tree, subtitle=subtitle, expand=False))
|
||||
|
||||
|
||||
@@ -845,7 +887,7 @@ def search_notes(
|
||||
if json_output or not _use_rich():
|
||||
_print_json(result)
|
||||
else:
|
||||
_display_search_results(result)
|
||||
_display_search_results(result, query=query or "")
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -27,46 +27,72 @@ READ_NOTE_RESULT = {
|
||||
}
|
||||
|
||||
SEARCH_RESULT = {
|
||||
"query": "test",
|
||||
# Real SearchResponse.model_dump() uses "current_page", not "page".
|
||||
# No "query" key in the response -- the query comes from the CLI argument.
|
||||
"total": 2,
|
||||
"page": 1,
|
||||
"current_page": 1,
|
||||
"page_size": 10,
|
||||
"has_more": False,
|
||||
"results": [
|
||||
{
|
||||
"type": "entity",
|
||||
"title": "Test Note",
|
||||
"permalink": "notes/test-note",
|
||||
"file_path": "notes/Test Note.md",
|
||||
"score": 0.95,
|
||||
"matched_chunk": "A snippet about test notes",
|
||||
"content": None,
|
||||
},
|
||||
{
|
||||
"type": "observation",
|
||||
"title": "Another Note",
|
||||
"permalink": "notes/another-note",
|
||||
"file_path": "notes/Another Note.md",
|
||||
"score": 0.72,
|
||||
"matched_chunk": None,
|
||||
"content": "Full content here",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
SEARCH_RESULT_EMPTY = {
|
||||
"query": "nothing",
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"current_page": 1,
|
||||
"page_size": 10,
|
||||
"has_more": False,
|
||||
"results": [],
|
||||
}
|
||||
|
||||
BUILD_CONTEXT_RESULT = {
|
||||
# Real GraphContext.model_dump() shape: results is a list of ContextResult dicts.
|
||||
# Each ContextResult has primary_result + observations + related_results.
|
||||
"results": [
|
||||
{
|
||||
"type": "entity",
|
||||
"title": "Related Note",
|
||||
"permalink": "notes/related",
|
||||
"relation_type": "references",
|
||||
"primary_result": {
|
||||
"type": "entity",
|
||||
"external_id": "abc123",
|
||||
"title": "Test Note",
|
||||
"permalink": "notes/test-note",
|
||||
"file_path": "notes/Test Note.md",
|
||||
"created_at": "2025-01-01T00:00:00",
|
||||
},
|
||||
"observations": [],
|
||||
"related_results": [
|
||||
{
|
||||
"type": "relation",
|
||||
"title": "Related Note",
|
||||
"permalink": "notes/related",
|
||||
"file_path": "notes/Related Note.md",
|
||||
"relation_type": "references",
|
||||
"created_at": "2025-01-01T00:00:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"metadata": {"uri": "notes/test-note", "depth": 1},
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
BUILD_CONTEXT_EMPTY = {
|
||||
@@ -74,16 +100,18 @@ BUILD_CONTEXT_EMPTY = {
|
||||
"metadata": {"uri": "notes/test-note", "depth": 1},
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
RECENT_ACTIVITY_RESULT = [
|
||||
# Real _extract_recent_rows output keys: type/title/permalink/file_path/created_at
|
||||
# (optional: project). No "updated_at" key in the real output.
|
||||
{
|
||||
"type": "entity",
|
||||
"title": "Note A",
|
||||
"permalink": "notes/note-a",
|
||||
"file_path": "notes/Note A.md",
|
||||
"created_at": "2025-01-01 00:00:00",
|
||||
"updated_at": "2025-01-01 12:00:00",
|
||||
},
|
||||
{
|
||||
"type": "entity",
|
||||
@@ -91,7 +119,6 @@ RECENT_ACTIVITY_RESULT = [
|
||||
"permalink": "notes/note-b",
|
||||
"file_path": "notes/Note B.md",
|
||||
"created_at": "2025-01-02 00:00:00",
|
||||
"updated_at": None,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -125,10 +152,11 @@ def test_search_notes_rich_output_default(mock_mcp):
|
||||
# Rich output should NOT be valid JSON
|
||||
with pytest.raises((json.JSONDecodeError, ValueError)):
|
||||
json.loads(result.output)
|
||||
# But it should contain the result titles
|
||||
# But it should contain the result titles and partial permalink
|
||||
assert "Test Note" in result.output
|
||||
assert "Another Note" in result.output
|
||||
assert "notes/test-note" in result.output
|
||||
# Rich may truncate long permalinks with ellipsis; check the prefix.
|
||||
assert "notes/test-no" in result.output
|
||||
|
||||
|
||||
@patch(
|
||||
@@ -287,7 +315,9 @@ def test_build_context_json_flag_overrides_tty(mock_mcp):
|
||||
assert result.exit_code == 0, f"CLI failed: {result.output}"
|
||||
data = json.loads(result.output)
|
||||
assert "results" in data
|
||||
assert data["results"][0]["title"] == "Related Note"
|
||||
# Real shape: results[i] is a ContextResult with primary_result nested inside.
|
||||
assert data["results"][0]["primary_result"]["title"] == "Test Note"
|
||||
assert data["results"][0]["related_results"][0]["title"] == "Related Note"
|
||||
|
||||
|
||||
@patch(
|
||||
|
||||
Reference in New Issue
Block a user