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
11 changes: 7 additions & 4 deletions src/semble/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import asyncio
import json
import sys
import warnings
from enum import Enum
Expand Down Expand Up @@ -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)
Expand All @@ -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))
9 changes: 5 additions & 4 deletions src/semble/mcp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import json
import logging
from collections import OrderedDict
from collections.abc import Sequence
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion src/semble/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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:
Expand Down
14 changes: 4 additions & 10 deletions src/semble/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import re
from typing import Any

from semble.types import Chunk, SearchResult

Expand Down Expand Up @@ -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]}
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
Expand Down
24 changes: 12 additions & 12 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Loading