diff --git a/src/semble/cli.py b/src/semble/cli.py index 441dd1c..aac944d 100644 --- a/src/semble/cli.py +++ b/src/semble/cli.py @@ -1,5 +1,6 @@ import argparse import asyncio +import json import sys import warnings from enum import Enum @@ -185,9 +186,10 @@ def _cli_main() -> None: if args.command == "search": results = index.search(args.query, top_k=args.top_k) if not results: - print("No results found.") + out = {"error": "No results found."} else: - print(format_results(f"Search results for: {args.query!r}", results)) + out = format_results(args.query, results) + print(json.dumps(out)) elif args.command == "find-related": chunk = resolve_chunk(index.chunks, args.file_path, args.line) @@ -196,6 +198,7 @@ def _cli_main() -> None: sys.exit(1) results = index.find_related(chunk, top_k=args.top_k) if not results: - print(f"No related chunks found for {args.file_path}:{args.line}.") + out = {"error": f"No related chunks found for {args.file_path}:{args.line}."} else: - print(format_results(f"Chunks related to {args.file_path}:{args.line}", results)) + out = format_results(f"Chunks related to {args.file_path}:{args.line}", results) + print(json.dumps(out)) diff --git a/src/semble/mcp.py b/src/semble/mcp.py index e502ac6..3aa526a 100644 --- a/src/semble/mcp.py +++ b/src/semble/mcp.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import logging from collections import OrderedDict from collections.abc import Sequence @@ -77,8 +78,8 @@ async def search( return str(exc) results = index.search(query, top_k=top_k) if not results: - return "No results found." - return format_results(f"Search results for: {query!r}", results) + return json.dumps({"error": "No results found."}) + return json.dumps(format_results(query, results)) @server.tool() async def find_related( @@ -107,8 +108,8 @@ async def find_related( ) results = index.find_related(chunk, top_k=top_k) if not results: - return f"No related chunks found for {file_path}:{line}." - return format_results(f"Chunks related to {file_path}:{line}", results) + return json.dumps({"error": f"No related chunks found for {file_path}:{line}."}) + return json.dumps(format_results(f"Chunks related to {file_path}:{line}", results)) return server diff --git a/src/semble/types.py b/src/semble/types.py index e6eee7e..3a46aa8 100644 --- a/src/semble/types.py +++ b/src/semble/types.py @@ -42,11 +42,14 @@ def location(self) -> str: def to_dict(self) -> dict[str, Any]: """Convert the dataclass to a dict.""" - return asdict(self) + d = asdict(self) + d["location"] = self.location + return d @classmethod def from_dict(cls: type[Chunk], data: dict[str, Any]) -> Chunk: """Create a Chunk from a dict.""" + data.pop("location", None) return cls(**data) @@ -57,6 +60,13 @@ class SearchResult: chunk: Chunk score: float + def to_dict(self) -> dict[str, Any]: + """Dump a search result to a dict.""" + return { + "chunk": self.chunk.to_dict(), + "score": self.score, + } + @dataclass(frozen=True, slots=True) class IndexStats: diff --git a/src/semble/utils.py b/src/semble/utils.py index f8c16f1..4b71395 100644 --- a/src/semble/utils.py +++ b/src/semble/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from typing import Any from semble.types import Chunk, SearchResult @@ -29,13 +30,6 @@ def resolve_chunk(chunks: list[Chunk], file_path: str, line: int) -> Chunk | Non return fallback -def format_results(header: str, results: list[SearchResult]) -> str: - """Render SearchResult objects as numbered, fenced code blocks.""" - lines: list[str] = [header, ""] - for i, r in enumerate(results, 1): - lines.append(f"## {i}. {r.chunk.location} [score={r.score:.3f}]") - lines.append("```") - lines.append(r.chunk.content.strip()) - lines.append("```") - lines.append("") - return "\n".join(lines) +def format_results(query: str, results: list[SearchResult]) -> dict[str, Any]: + """Render SearchResult objects as a JSONable object.""" + return {"query": query, "results": [r.to_dict() for r in results]} diff --git a/tests/test_cli.py b/tests/test_cli.py index 7c14a50..b998e44 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,7 +57,7 @@ def test_cli_search( @pytest.mark.parametrize( ("scenario", "expected_stdout", "expected_stderr", "expected_exit_code"), [ - ("with_results", ["src/bar.py", "0.800"], None, None), + ("with_results", ["src/bar.py", "0.8"], None, None), ("no_results", ["No related chunks found"], None, None), ("unknown_chunk", [], "No chunk found", 1), ], diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 2d2494a..900477a 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -88,19 +88,19 @@ def test_is_git_url(path: str, expected: bool) -> None: def test_format_results() -> None: """_format_results: empty list → header only; with results → numbered fenced blocks with scores.""" - empty_out = format_results("My header", []) - assert "My header" in empty_out - assert "```" not in empty_out + empty_out = format_results("query", []) + assert empty_out == {"query": "query", "results": []} chunks = [make_chunk(f"def fn_{i}(): pass", f"f{i}.py") for i in range(3)] results = [SearchResult(chunk=c, score=round(0.1 * (i + 1), 3)) for i, c in enumerate(chunks)] - out = format_results("Results for: 'foo'", results) - assert "Results for: 'foo'" in out - assert out.count("```") >= len(results) * 2 # opening + closing fence each - for i, c in enumerate(chunks, start=1): - assert f"## {i}." in out - assert c.content in out - assert "0.100" in out and "0.200" in out and "0.300" in out + out = format_results("foo", results) + assert out["query"] == "foo" + contents = set(x["chunk"]["content"] for x in out["results"]) + scores = set(x["score"] for x in out["results"]) + for chunk in chunks: + assert chunk.content in contents + for score in [0.1, 0.2, 0.3]: + assert score in scores @pytest.mark.anyio @@ -189,7 +189,7 @@ async def test_tool_index_failure(cache: _IndexCache, tool: str, args: dict[str, "search", [SearchResult(chunk=make_chunk("def bar(): pass", "src/bar.py"), score=0.9)], None, - ["bar", "0.900"], + ["bar", "0.9"], id="search_with_results", ), pytest.param( @@ -207,7 +207,7 @@ async def test_tool_index_failure(cache: _IndexCache, tool: str, args: dict[str, "find_related", [SearchResult(chunk=make_chunk("class Foo: pass", "src/foo.py"), score=0.8)], [make_chunk("class Foo: pass", "src/foo.py")], - ["src/foo.py:1", "0.800"], + ["src/foo.py:1", "0.8"], id="find_related_with_results", ), pytest.param(