ragmir 6.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ragmir/__init__.py +55 -0
- ragmir/_client.py +567 -0
- ragmir/_errors.py +28 -0
- ragmir/_launcher.py +146 -0
- ragmir/_models.py +214 -0
- ragmir/_setup.py +67 -0
- ragmir/_shared.py +46 -0
- ragmir/_version.py +7 -0
- ragmir/py.typed +0 -0
- ragmir-6.11.0.dist-info/METADATA +84 -0
- ragmir-6.11.0.dist-info/RECORD +13 -0
- ragmir-6.11.0.dist-info/WHEEL +4 -0
- ragmir-6.11.0.dist-info/licenses/LICENSE +661 -0
ragmir/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Cited local retrieval for Python: search a project's indexed code and documents with Ragmir.
|
|
2
|
+
|
|
3
|
+
>>> from ragmir import Ragmir
|
|
4
|
+
>>> with Ragmir("path/to/project") as rag:
|
|
5
|
+
... for passage in rag.search("who approves a production release?"):
|
|
6
|
+
... print(passage.citation, passage.text)
|
|
7
|
+
|
|
8
|
+
Ragmir runs locally through Node.js (a project install, a global ``rgr``, or the pinned npm
|
|
9
|
+
release through npx) or the official Docker image. See https://github.com/jcode-works/jcode-ragmir.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._client import DEFAULT_TIMEOUT, AsyncRagmir, Ragmir
|
|
13
|
+
from ._errors import (
|
|
14
|
+
RagmirCommandError,
|
|
15
|
+
RagmirError,
|
|
16
|
+
RagmirNotFoundError,
|
|
17
|
+
RagmirServerError,
|
|
18
|
+
RagmirToolError,
|
|
19
|
+
)
|
|
20
|
+
from ._launcher import Launcher, resolve_launcher
|
|
21
|
+
from ._models import (
|
|
22
|
+
ExpandedCitation,
|
|
23
|
+
IndexedFile,
|
|
24
|
+
IngestReport,
|
|
25
|
+
Passage,
|
|
26
|
+
SourceCatalog,
|
|
27
|
+
Status,
|
|
28
|
+
)
|
|
29
|
+
from ._setup import add_sources, init_project, run_cli
|
|
30
|
+
from ._shared import shared_client
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_TIMEOUT",
|
|
35
|
+
"AsyncRagmir",
|
|
36
|
+
"ExpandedCitation",
|
|
37
|
+
"IndexedFile",
|
|
38
|
+
"IngestReport",
|
|
39
|
+
"Launcher",
|
|
40
|
+
"Passage",
|
|
41
|
+
"Ragmir",
|
|
42
|
+
"RagmirCommandError",
|
|
43
|
+
"RagmirError",
|
|
44
|
+
"RagmirNotFoundError",
|
|
45
|
+
"RagmirServerError",
|
|
46
|
+
"RagmirToolError",
|
|
47
|
+
"SourceCatalog",
|
|
48
|
+
"Status",
|
|
49
|
+
"__version__",
|
|
50
|
+
"add_sources",
|
|
51
|
+
"init_project",
|
|
52
|
+
"resolve_launcher",
|
|
53
|
+
"run_cli",
|
|
54
|
+
"shared_client",
|
|
55
|
+
]
|
ragmir/_client.py
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"""Clients that keep one Ragmir MCP server running per project and reuse it for every call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Coroutine, Mapping, Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, TextIO, TypeVar
|
|
15
|
+
|
|
16
|
+
from mcp import Client, Implementation, MCPError, StdioServerParameters, stdio_client
|
|
17
|
+
from mcp.types import TextContent
|
|
18
|
+
|
|
19
|
+
from ._errors import RagmirError, RagmirServerError, RagmirToolError
|
|
20
|
+
from ._launcher import resolve_launcher
|
|
21
|
+
from ._models import ExpandedCitation, IngestReport, Passage, SourceCatalog, Status
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("ragmir")
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
DEFAULT_TIMEOUT = 120.0
|
|
29
|
+
"""Seconds to wait for the server to start and for each call to answer."""
|
|
30
|
+
|
|
31
|
+
# The server caps every answer at its configured mcpMaxOutputBytes; ask for that ceiling.
|
|
32
|
+
_MAX_OUTPUT_BYTES = 1_048_576
|
|
33
|
+
_INGEST_WAIT_SECONDS = 60
|
|
34
|
+
_STDERR_TAIL_CHARACTERS = 2_000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AsyncRagmir:
|
|
38
|
+
"""Async client for one Ragmir knowledge base, for asyncio applications.
|
|
39
|
+
|
|
40
|
+
The MCP server starts on the first call, or with ``connect()``, and serves every later call. A
|
|
41
|
+
background task owns the connection, so any task may call or close the client. Close it with
|
|
42
|
+
``aclose()``, or use ``async with``.
|
|
43
|
+
|
|
44
|
+
>>> async with AsyncRagmir("path/to/project") as rag:
|
|
45
|
+
... for passage in await rag.search("release approval"):
|
|
46
|
+
... print(passage.citation, passage.text)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
project_root: str | os.PathLike[str] = ".",
|
|
52
|
+
*,
|
|
53
|
+
command: Sequence[str] | str | None = None,
|
|
54
|
+
env: Mapping[str, str] | None = None,
|
|
55
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.project_root = Path(project_root).expanduser().resolve()
|
|
58
|
+
self._command = command
|
|
59
|
+
self._env = dict(env) if env is not None else {}
|
|
60
|
+
self._timeout = timeout
|
|
61
|
+
self._client: Client | None = None
|
|
62
|
+
self._owner: asyncio.Task[None] | None = None
|
|
63
|
+
self._stop: asyncio.Event | None = None
|
|
64
|
+
self._lock: asyncio.Lock | None = None
|
|
65
|
+
self._stderr: TextIO | None = None
|
|
66
|
+
self._closed = False
|
|
67
|
+
|
|
68
|
+
async def __aenter__(self) -> AsyncRagmir:
|
|
69
|
+
await self.connect()
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(self, *_exc: object) -> None:
|
|
73
|
+
await self.aclose()
|
|
74
|
+
|
|
75
|
+
async def connect(self) -> None:
|
|
76
|
+
"""Start the MCP server now instead of on the first call."""
|
|
77
|
+
await self._session()
|
|
78
|
+
|
|
79
|
+
async def aclose(self) -> None:
|
|
80
|
+
"""Stop the MCP server. The client cannot be used afterwards."""
|
|
81
|
+
self._closed = True
|
|
82
|
+
await self._shutdown()
|
|
83
|
+
if self._stderr is not None:
|
|
84
|
+
self._stderr.close()
|
|
85
|
+
self._stderr = None
|
|
86
|
+
|
|
87
|
+
async def search(
|
|
88
|
+
self,
|
|
89
|
+
query: str,
|
|
90
|
+
*,
|
|
91
|
+
top_k: int | None = None,
|
|
92
|
+
include_paths: Sequence[str] | None = None,
|
|
93
|
+
exclude_paths: Sequence[str] | None = None,
|
|
94
|
+
scope: str | None = None,
|
|
95
|
+
max_chunks_per_document: int | None = None,
|
|
96
|
+
context_radius: int | None = None,
|
|
97
|
+
) -> list[Passage]:
|
|
98
|
+
"""Cited passages that answer ``query``; an empty list means the base holds no evidence.
|
|
99
|
+
|
|
100
|
+
``include_paths`` and ``exclude_paths`` take folders, files, or globs such as ``**/*.pdf``.
|
|
101
|
+
``top_k`` is capped by the base's ``mcpMaxTopK`` setting (10 by default).
|
|
102
|
+
"""
|
|
103
|
+
arguments = _search_arguments(
|
|
104
|
+
top_k, include_paths, exclude_paths, scope, max_chunks_per_document, context_radius
|
|
105
|
+
)
|
|
106
|
+
data = await self._call_json("ragmir_search", {"query": query, **arguments})
|
|
107
|
+
return [Passage.from_json(item) for item in _list(data)]
|
|
108
|
+
|
|
109
|
+
async def search_many(
|
|
110
|
+
self,
|
|
111
|
+
queries: Sequence[str],
|
|
112
|
+
*,
|
|
113
|
+
top_k: int | None = None,
|
|
114
|
+
include_paths: Sequence[str] | None = None,
|
|
115
|
+
exclude_paths: Sequence[str] | None = None,
|
|
116
|
+
scope: str | None = None,
|
|
117
|
+
max_chunks_per_document: int | None = None,
|
|
118
|
+
context_radius: int | None = None,
|
|
119
|
+
) -> dict[str, list[Passage]]:
|
|
120
|
+
"""Up to five related questions in one call, each with its own cited passages."""
|
|
121
|
+
arguments = _search_arguments(
|
|
122
|
+
top_k, include_paths, exclude_paths, scope, max_chunks_per_document, context_radius
|
|
123
|
+
)
|
|
124
|
+
data = await self._call_json("ragmir_search", {"queries": list(queries), **arguments})
|
|
125
|
+
if len(queries) == 1:
|
|
126
|
+
return {queries[0]: [Passage.from_json(item) for item in _list(data)]}
|
|
127
|
+
return {
|
|
128
|
+
str(group.get("query", "")): [
|
|
129
|
+
Passage.from_json(item) for item in _list(group.get("results"))
|
|
130
|
+
]
|
|
131
|
+
for group in _list(data)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async def expand(
|
|
135
|
+
self,
|
|
136
|
+
citation: str,
|
|
137
|
+
*,
|
|
138
|
+
context_radius: int | None = None,
|
|
139
|
+
expected_evidence_id: str | None = None,
|
|
140
|
+
) -> ExpandedCitation:
|
|
141
|
+
"""A citation with up to three neighbouring passages on each side.
|
|
142
|
+
|
|
143
|
+
Expand ``next_citation`` with the same radius to read the rest of the document. Pass the
|
|
144
|
+
``evidence_id`` of a search result to fail if the indexed passage changed since.
|
|
145
|
+
"""
|
|
146
|
+
arguments: dict[str, Any] = {"citation": citation, "maxBytes": _MAX_OUTPUT_BYTES}
|
|
147
|
+
if context_radius is not None:
|
|
148
|
+
arguments["contextRadius"] = context_radius
|
|
149
|
+
if expected_evidence_id is not None:
|
|
150
|
+
arguments["expectedEvidenceId"] = expected_evidence_id
|
|
151
|
+
data = await self._call_json("ragmir_expand", arguments)
|
|
152
|
+
return ExpandedCitation.from_json(_mapping(data))
|
|
153
|
+
|
|
154
|
+
async def sources(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
paths: Sequence[str] | None = None,
|
|
158
|
+
offset: int | None = None,
|
|
159
|
+
limit: int | None = None,
|
|
160
|
+
) -> SourceCatalog:
|
|
161
|
+
"""One page of the indexed documents, optionally under folders or matching globs."""
|
|
162
|
+
arguments: dict[str, Any] = {"maxBytes": _MAX_OUTPUT_BYTES}
|
|
163
|
+
if paths is not None:
|
|
164
|
+
arguments["paths"] = list(paths)
|
|
165
|
+
if offset is not None:
|
|
166
|
+
arguments["offset"] = offset
|
|
167
|
+
if limit is not None:
|
|
168
|
+
arguments["limit"] = limit
|
|
169
|
+
data = await self._call_json("ragmir_sources", arguments)
|
|
170
|
+
return SourceCatalog.from_json(_mapping(data))
|
|
171
|
+
|
|
172
|
+
async def status(self) -> Status:
|
|
173
|
+
"""Readiness, freshness, and identity of the knowledge base."""
|
|
174
|
+
return Status.from_json(_mapping(await self._call_json("ragmir_status", {})))
|
|
175
|
+
|
|
176
|
+
async def audit(self, *, detail: bool = False) -> dict[str, Any]:
|
|
177
|
+
"""Missing, stale, unsupported, and duplicated sources, as the server reports them."""
|
|
178
|
+
data = await self._call_json(
|
|
179
|
+
"ragmir_audit", {"detail": detail, "maxBytes": _MAX_OUTPUT_BYTES}
|
|
180
|
+
)
|
|
181
|
+
return dict(_mapping(data))
|
|
182
|
+
|
|
183
|
+
async def ingest(self, *, wait: bool = True) -> IngestReport:
|
|
184
|
+
"""Refresh the index incrementally: only new and changed files are read again.
|
|
185
|
+
|
|
186
|
+
With ``wait=False`` the run continues in the background and the report says ``running``.
|
|
187
|
+
Ragmir refuses when the base disables MCP indexing or ``rgr security-audit`` warns; run
|
|
188
|
+
``run_cli(project, "ingest")`` from an operator script in that case.
|
|
189
|
+
"""
|
|
190
|
+
while True:
|
|
191
|
+
data = _mapping(
|
|
192
|
+
await self._call_json(
|
|
193
|
+
"ragmir_ingest",
|
|
194
|
+
{"waitSeconds": _INGEST_WAIT_SECONDS if wait else 0},
|
|
195
|
+
timeout=_INGEST_WAIT_SECONDS + self._timeout,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
report = IngestReport.from_json(data)
|
|
199
|
+
if report.status != "running" or not wait:
|
|
200
|
+
return report
|
|
201
|
+
|
|
202
|
+
async def _call_json(
|
|
203
|
+
self, tool: str, arguments: dict[str, Any], *, timeout: float | None = None
|
|
204
|
+
) -> Any:
|
|
205
|
+
client = await self._session()
|
|
206
|
+
try:
|
|
207
|
+
result = await client.call_tool(
|
|
208
|
+
tool, arguments, read_timeout_seconds=timeout or self._timeout
|
|
209
|
+
)
|
|
210
|
+
except MCPError as error:
|
|
211
|
+
raise RagmirServerError(f"{tool} failed: {error}") from error
|
|
212
|
+
except Exception as error:
|
|
213
|
+
# Tool failures come back as results; an exception means the connection broke.
|
|
214
|
+
raise RagmirServerError(
|
|
215
|
+
f"{tool} lost the Ragmir server: {error}.{self._stderr_tail()}"
|
|
216
|
+
) from error
|
|
217
|
+
texts = [item.text for item in result.content if isinstance(item, TextContent)]
|
|
218
|
+
if result.is_error:
|
|
219
|
+
raise RagmirToolError("\n".join(texts) or f"{tool} failed.")
|
|
220
|
+
if not texts:
|
|
221
|
+
raise RagmirServerError(f"{tool} returned no content.")
|
|
222
|
+
for notice in texts[1:]:
|
|
223
|
+
logger.warning("%s: %s", tool, notice)
|
|
224
|
+
try:
|
|
225
|
+
return json.loads(texts[0])
|
|
226
|
+
except json.JSONDecodeError as error:
|
|
227
|
+
raise RagmirServerError(f"{tool} returned invalid JSON: {texts[0][:200]}") from error
|
|
228
|
+
|
|
229
|
+
async def _session(self) -> Client:
|
|
230
|
+
if self._lock is None:
|
|
231
|
+
self._lock = asyncio.Lock()
|
|
232
|
+
async with self._lock:
|
|
233
|
+
if self._closed:
|
|
234
|
+
raise RagmirError("This Ragmir client is closed.")
|
|
235
|
+
if self._client is not None and self._owner is not None and not self._owner.done():
|
|
236
|
+
return self._client
|
|
237
|
+
# The server stopped since the last call: start a fresh one.
|
|
238
|
+
await self._shutdown()
|
|
239
|
+
loop = asyncio.get_running_loop()
|
|
240
|
+
ready: asyncio.Future[Client] = loop.create_future()
|
|
241
|
+
self._stop = asyncio.Event()
|
|
242
|
+
self._owner = asyncio.create_task(
|
|
243
|
+
self._own_session(ready, self._stop), name="ragmir-mcp-session"
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
self._client = await asyncio.wait_for(asyncio.shield(ready), self._timeout)
|
|
247
|
+
except Exception as error:
|
|
248
|
+
await self._shutdown()
|
|
249
|
+
if isinstance(error, RagmirError):
|
|
250
|
+
raise
|
|
251
|
+
raise RagmirServerError(
|
|
252
|
+
f"The Ragmir MCP server did not start for {self.project_root}: {error}."
|
|
253
|
+
f"{self._stderr_tail()}"
|
|
254
|
+
) from error
|
|
255
|
+
return self._client
|
|
256
|
+
|
|
257
|
+
async def _own_session(self, ready: asyncio.Future[Client], stop: asyncio.Event) -> None:
|
|
258
|
+
"""Enter and leave the MCP connection in this one task, as anyio requires."""
|
|
259
|
+
try:
|
|
260
|
+
launcher = resolve_launcher(self.project_root, self._command)
|
|
261
|
+
argv = launcher.command("serve-mcp", interactive=True)
|
|
262
|
+
env = {**os.environ, **self._env}
|
|
263
|
+
if launcher.kind != "docker":
|
|
264
|
+
env["RAGMIR_PROJECT_ROOT"] = str(self.project_root)
|
|
265
|
+
if self._stderr is None:
|
|
266
|
+
# Kept for the client's lifetime to report the server's output; aclose() closes it.
|
|
267
|
+
self._stderr = tempfile.TemporaryFile("w+", encoding="utf-8") # noqa: SIM115
|
|
268
|
+
params = StdioServerParameters(
|
|
269
|
+
command=argv[0], args=argv[1:], env=env, cwd=str(self.project_root)
|
|
270
|
+
)
|
|
271
|
+
async with Client(
|
|
272
|
+
stdio_client(params, errlog=self._stderr),
|
|
273
|
+
mode="legacy",
|
|
274
|
+
read_timeout_seconds=self._timeout,
|
|
275
|
+
client_info=Implementation(name="ragmir-python", version=__version__),
|
|
276
|
+
) as client:
|
|
277
|
+
ready.set_result(client)
|
|
278
|
+
await stop.wait()
|
|
279
|
+
except BaseException as error:
|
|
280
|
+
if not ready.done():
|
|
281
|
+
ready.set_exception(error)
|
|
282
|
+
elif not isinstance(error, asyncio.CancelledError):
|
|
283
|
+
logger.warning("The Ragmir MCP server stopped: %s", error)
|
|
284
|
+
if isinstance(error, asyncio.CancelledError | KeyboardInterrupt | SystemExit):
|
|
285
|
+
raise
|
|
286
|
+
|
|
287
|
+
async def _shutdown(self) -> None:
|
|
288
|
+
owner, stop = self._owner, self._stop
|
|
289
|
+
self._client = None
|
|
290
|
+
self._owner = None
|
|
291
|
+
self._stop = None
|
|
292
|
+
if owner is None:
|
|
293
|
+
return
|
|
294
|
+
if stop is not None:
|
|
295
|
+
stop.set()
|
|
296
|
+
try:
|
|
297
|
+
await asyncio.wait_for(owner, timeout=10)
|
|
298
|
+
except (TimeoutError, Exception):
|
|
299
|
+
owner.cancel()
|
|
300
|
+
|
|
301
|
+
def _stderr_tail(self) -> str:
|
|
302
|
+
if self._stderr is None:
|
|
303
|
+
return ""
|
|
304
|
+
try:
|
|
305
|
+
self._stderr.flush()
|
|
306
|
+
self._stderr.seek(0)
|
|
307
|
+
tail = self._stderr.read()[-_STDERR_TAIL_CHARACTERS:].strip()
|
|
308
|
+
except (OSError, ValueError):
|
|
309
|
+
return ""
|
|
310
|
+
return f"\nServer output:\n{tail}" if tail else ""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class Ragmir:
|
|
314
|
+
"""Client for one Ragmir knowledge base, from synchronous or asynchronous code.
|
|
315
|
+
|
|
316
|
+
One MCP server runs per client on a private event loop thread, so the client works from any
|
|
317
|
+
thread, a notebook, or a web framework. Each method has an ``a``-prefixed async twin for
|
|
318
|
+
asyncio code. Close the client with ``close()``, or use ``with``.
|
|
319
|
+
|
|
320
|
+
>>> with Ragmir("path/to/project") as rag:
|
|
321
|
+
... for passage in rag.search("release approval"):
|
|
322
|
+
... print(passage.citation, passage.text)
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
project_root: str | os.PathLike[str] = ".",
|
|
328
|
+
*,
|
|
329
|
+
command: Sequence[str] | str | None = None,
|
|
330
|
+
env: Mapping[str, str] | None = None,
|
|
331
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
332
|
+
) -> None:
|
|
333
|
+
self._async = AsyncRagmir(project_root, command=command, env=env, timeout=timeout)
|
|
334
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
335
|
+
self._thread: threading.Thread | None = None
|
|
336
|
+
self._lock = threading.Lock()
|
|
337
|
+
self._closed = False
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def project_root(self) -> Path:
|
|
341
|
+
return self._async.project_root
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def closed(self) -> bool:
|
|
345
|
+
return self._closed
|
|
346
|
+
|
|
347
|
+
def __enter__(self) -> Ragmir:
|
|
348
|
+
return self
|
|
349
|
+
|
|
350
|
+
def __exit__(self, *_exc: object) -> None:
|
|
351
|
+
self.close()
|
|
352
|
+
|
|
353
|
+
def connect(self) -> None:
|
|
354
|
+
"""Start the MCP server now instead of on the first call."""
|
|
355
|
+
self._run(self._async.connect())
|
|
356
|
+
|
|
357
|
+
def close(self) -> None:
|
|
358
|
+
"""Stop the MCP server and its thread. The client cannot be used afterwards."""
|
|
359
|
+
with self._lock:
|
|
360
|
+
if self._closed:
|
|
361
|
+
return
|
|
362
|
+
self._closed = True
|
|
363
|
+
loop, thread = self._loop, self._thread
|
|
364
|
+
if loop is None or thread is None:
|
|
365
|
+
return
|
|
366
|
+
try:
|
|
367
|
+
asyncio.run_coroutine_threadsafe(self._async.aclose(), loop).result(timeout=30)
|
|
368
|
+
except (concurrent.futures.TimeoutError, RagmirError, RuntimeError):
|
|
369
|
+
logger.warning("The Ragmir MCP server did not stop cleanly.")
|
|
370
|
+
finally:
|
|
371
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
372
|
+
thread.join(timeout=10)
|
|
373
|
+
|
|
374
|
+
def search(
|
|
375
|
+
self,
|
|
376
|
+
query: str,
|
|
377
|
+
*,
|
|
378
|
+
top_k: int | None = None,
|
|
379
|
+
include_paths: Sequence[str] | None = None,
|
|
380
|
+
exclude_paths: Sequence[str] | None = None,
|
|
381
|
+
scope: str | None = None,
|
|
382
|
+
max_chunks_per_document: int | None = None,
|
|
383
|
+
context_radius: int | None = None,
|
|
384
|
+
) -> list[Passage]:
|
|
385
|
+
"""Cited passages that answer ``query``; an empty list means the base holds no evidence."""
|
|
386
|
+
return self._run(
|
|
387
|
+
self._async.search(
|
|
388
|
+
query,
|
|
389
|
+
top_k=top_k,
|
|
390
|
+
include_paths=include_paths,
|
|
391
|
+
exclude_paths=exclude_paths,
|
|
392
|
+
scope=scope,
|
|
393
|
+
max_chunks_per_document=max_chunks_per_document,
|
|
394
|
+
context_radius=context_radius,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
async def asearch(
|
|
399
|
+
self,
|
|
400
|
+
query: str,
|
|
401
|
+
*,
|
|
402
|
+
top_k: int | None = None,
|
|
403
|
+
include_paths: Sequence[str] | None = None,
|
|
404
|
+
exclude_paths: Sequence[str] | None = None,
|
|
405
|
+
scope: str | None = None,
|
|
406
|
+
max_chunks_per_document: int | None = None,
|
|
407
|
+
context_radius: int | None = None,
|
|
408
|
+
) -> list[Passage]:
|
|
409
|
+
"""Async twin of ``search``."""
|
|
410
|
+
return await self._arun(
|
|
411
|
+
self._async.search(
|
|
412
|
+
query,
|
|
413
|
+
top_k=top_k,
|
|
414
|
+
include_paths=include_paths,
|
|
415
|
+
exclude_paths=exclude_paths,
|
|
416
|
+
scope=scope,
|
|
417
|
+
max_chunks_per_document=max_chunks_per_document,
|
|
418
|
+
context_radius=context_radius,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def search_many(self, queries: Sequence[str], **options: Any) -> dict[str, list[Passage]]:
|
|
423
|
+
"""Up to five related questions in one call, each with its own cited passages."""
|
|
424
|
+
return self._run(self._async.search_many(queries, **options))
|
|
425
|
+
|
|
426
|
+
async def asearch_many(
|
|
427
|
+
self, queries: Sequence[str], **options: Any
|
|
428
|
+
) -> dict[str, list[Passage]]:
|
|
429
|
+
"""Async twin of ``search_many``."""
|
|
430
|
+
return await self._arun(self._async.search_many(queries, **options))
|
|
431
|
+
|
|
432
|
+
def expand(
|
|
433
|
+
self,
|
|
434
|
+
citation: str,
|
|
435
|
+
*,
|
|
436
|
+
context_radius: int | None = None,
|
|
437
|
+
expected_evidence_id: str | None = None,
|
|
438
|
+
) -> ExpandedCitation:
|
|
439
|
+
"""A citation with its neighbours; expand ``next_citation`` to read on."""
|
|
440
|
+
return self._run(
|
|
441
|
+
self._async.expand(
|
|
442
|
+
citation, context_radius=context_radius, expected_evidence_id=expected_evidence_id
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
async def aexpand(
|
|
447
|
+
self,
|
|
448
|
+
citation: str,
|
|
449
|
+
*,
|
|
450
|
+
context_radius: int | None = None,
|
|
451
|
+
expected_evidence_id: str | None = None,
|
|
452
|
+
) -> ExpandedCitation:
|
|
453
|
+
"""Async twin of ``expand``."""
|
|
454
|
+
return await self._arun(
|
|
455
|
+
self._async.expand(
|
|
456
|
+
citation, context_radius=context_radius, expected_evidence_id=expected_evidence_id
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def sources(
|
|
461
|
+
self,
|
|
462
|
+
*,
|
|
463
|
+
paths: Sequence[str] | None = None,
|
|
464
|
+
offset: int | None = None,
|
|
465
|
+
limit: int | None = None,
|
|
466
|
+
) -> SourceCatalog:
|
|
467
|
+
"""One page of the indexed documents, optionally under folders or matching globs."""
|
|
468
|
+
return self._run(self._async.sources(paths=paths, offset=offset, limit=limit))
|
|
469
|
+
|
|
470
|
+
async def asources(
|
|
471
|
+
self,
|
|
472
|
+
*,
|
|
473
|
+
paths: Sequence[str] | None = None,
|
|
474
|
+
offset: int | None = None,
|
|
475
|
+
limit: int | None = None,
|
|
476
|
+
) -> SourceCatalog:
|
|
477
|
+
"""Async twin of ``sources``."""
|
|
478
|
+
return await self._arun(self._async.sources(paths=paths, offset=offset, limit=limit))
|
|
479
|
+
|
|
480
|
+
def status(self) -> Status:
|
|
481
|
+
"""Readiness, freshness, and identity of the knowledge base."""
|
|
482
|
+
return self._run(self._async.status())
|
|
483
|
+
|
|
484
|
+
async def astatus(self) -> Status:
|
|
485
|
+
"""Async twin of ``status``."""
|
|
486
|
+
return await self._arun(self._async.status())
|
|
487
|
+
|
|
488
|
+
def audit(self, *, detail: bool = False) -> dict[str, Any]:
|
|
489
|
+
"""Missing, stale, unsupported, and duplicated sources."""
|
|
490
|
+
return self._run(self._async.audit(detail=detail))
|
|
491
|
+
|
|
492
|
+
async def aaudit(self, *, detail: bool = False) -> dict[str, Any]:
|
|
493
|
+
"""Async twin of ``audit``."""
|
|
494
|
+
return await self._arun(self._async.audit(detail=detail))
|
|
495
|
+
|
|
496
|
+
def ingest(self, *, wait: bool = True) -> IngestReport:
|
|
497
|
+
"""Refresh the index incrementally; only new and changed files are read again."""
|
|
498
|
+
return self._run(self._async.ingest(wait=wait))
|
|
499
|
+
|
|
500
|
+
async def aingest(self, *, wait: bool = True) -> IngestReport:
|
|
501
|
+
"""Async twin of ``ingest``."""
|
|
502
|
+
return await self._arun(self._async.ingest(wait=wait))
|
|
503
|
+
|
|
504
|
+
def _submit(self, coroutine: Coroutine[Any, Any, T]) -> concurrent.futures.Future[T]:
|
|
505
|
+
with self._lock:
|
|
506
|
+
if self._closed:
|
|
507
|
+
coroutine.close()
|
|
508
|
+
raise RagmirError("This Ragmir client is closed.")
|
|
509
|
+
if self._loop is None:
|
|
510
|
+
loop = asyncio.new_event_loop()
|
|
511
|
+
# A daemon thread never blocks interpreter exit; the server exits on stdin EOF.
|
|
512
|
+
thread = threading.Thread(
|
|
513
|
+
target=_run_loop, args=(loop,), name="ragmir-mcp", daemon=True
|
|
514
|
+
)
|
|
515
|
+
thread.start()
|
|
516
|
+
self._loop, self._thread = loop, thread
|
|
517
|
+
return asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
518
|
+
|
|
519
|
+
def _run(self, coroutine: Coroutine[Any, Any, T]) -> T:
|
|
520
|
+
return self._submit(coroutine).result()
|
|
521
|
+
|
|
522
|
+
async def _arun(self, coroutine: Coroutine[Any, Any, T]) -> T:
|
|
523
|
+
return await asyncio.wrap_future(self._submit(coroutine))
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _run_loop(loop: asyncio.AbstractEventLoop) -> None:
|
|
527
|
+
asyncio.set_event_loop(loop)
|
|
528
|
+
try:
|
|
529
|
+
loop.run_forever()
|
|
530
|
+
finally:
|
|
531
|
+
loop.close()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _search_arguments(
|
|
535
|
+
top_k: int | None,
|
|
536
|
+
include_paths: Sequence[str] | None,
|
|
537
|
+
exclude_paths: Sequence[str] | None,
|
|
538
|
+
scope: str | None,
|
|
539
|
+
max_chunks_per_document: int | None,
|
|
540
|
+
context_radius: int | None,
|
|
541
|
+
) -> dict[str, Any]:
|
|
542
|
+
# Full passages, every time: a library caller expects the same answer to the same question,
|
|
543
|
+
# so passages already returned in the session are not shortened to stubs.
|
|
544
|
+
arguments: dict[str, Any] = {"compact": False, "seen": "include", "maxBytes": _MAX_OUTPUT_BYTES}
|
|
545
|
+
if top_k is not None:
|
|
546
|
+
arguments["topK"] = top_k
|
|
547
|
+
if include_paths is not None:
|
|
548
|
+
arguments["includePaths"] = list(include_paths)
|
|
549
|
+
if exclude_paths is not None:
|
|
550
|
+
arguments["excludePaths"] = list(exclude_paths)
|
|
551
|
+
if scope is not None:
|
|
552
|
+
arguments["scope"] = scope
|
|
553
|
+
if max_chunks_per_document is not None:
|
|
554
|
+
arguments["maxChunksPerDocument"] = max_chunks_per_document
|
|
555
|
+
if context_radius is not None:
|
|
556
|
+
arguments["contextRadius"] = context_radius
|
|
557
|
+
return arguments
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _list(value: object) -> list[Mapping[str, Any]]:
|
|
561
|
+
return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _mapping(value: object) -> Mapping[str, Any]:
|
|
565
|
+
if not isinstance(value, Mapping):
|
|
566
|
+
raise RagmirServerError("Ragmir returned an unexpected answer.")
|
|
567
|
+
return value
|
ragmir/_errors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Errors raised by the Ragmir Python client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RagmirError(Exception):
|
|
7
|
+
"""Base class of every error the Ragmir client raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RagmirNotFoundError(RagmirError):
|
|
11
|
+
"""No way to run Ragmir was found: no local install, no Node.js with npx, and no Docker."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RagmirServerError(RagmirError):
|
|
15
|
+
"""The Ragmir MCP server could not start, or stopped answering."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RagmirToolError(RagmirError):
|
|
19
|
+
"""Ragmir refused or failed a request; the message says why and what to run instead."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RagmirCommandError(RagmirError):
|
|
23
|
+
"""A Ragmir command such as ``init`` or ``sources add`` failed."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, returncode: int, output: str) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.returncode = returncode
|
|
28
|
+
self.output = output
|