Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# ragcore-sdk
|
||||
|
||||
Typed Python client for RAGCore's `/v1/search`, `/v1/context`, and
|
||||
`/v1/answers` endpoints.
|
||||
|
||||
```python
|
||||
from ragcore_sdk import RAGCoreClient
|
||||
|
||||
with RAGCoreClient(base_url="https://ragcore.example.internal", token="...") as client:
|
||||
response = client.search(query="What is the refund policy?")
|
||||
for result in response.results:
|
||||
print(result.rank, result.citation.source_uri)
|
||||
```
|
||||
|
||||
Streaming answers:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from ragcore_sdk import AsyncRAGCoreClient
|
||||
|
||||
async def main() -> None:
|
||||
async with AsyncRAGCoreClient(base_url="https://ragcore.example.internal", token="...") as client:
|
||||
async for event in client.answer_stream(query="What is the refund policy?"):
|
||||
print(event.type, event.data)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
This SDK covers exactly the endpoints RAGCore actually implements:
|
||||
`search`, `context`, and `answers` (sync and SSE-streaming). It does not
|
||||
expose `get_citation`/`get_job` methods — those paths exist only in the
|
||||
aspirational OpenAPI contract, not as real routes in this codebase, so
|
||||
the SDK does not fabricate a client surface for them.
|
||||
|
||||
## Errors
|
||||
|
||||
Every non-2xx response RAGCore returns is an RFC7807 Problem Details body.
|
||||
The SDK raises `RAGCoreAPIError` (carrying the parsed `ProblemDetails`) for
|
||||
those, and `RAGCoreConnectionError` for network-level failures that
|
||||
persist after retries are exhausted. Retries only ever apply to this
|
||||
SDK's own read-only, side-effect-free requests (search/context/answers are
|
||||
GET-equivalent queries) on connection failures and `503` responses --
|
||||
never on `4xx` client errors.
|
||||
|
||||
The bearer token is never logged or included in exception messages.
|
||||
@@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "ragcore-sdk"
|
||||
version = "1.2.1"
|
||||
description = "Typed Python client for the RAGCore search/context/answers API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13,<3.14"
|
||||
license = { text = "Proprietary" }
|
||||
authors = [{ name = "ITWorx" }]
|
||||
dependencies = [
|
||||
"httpx>=0.28,<1",
|
||||
"pydantic>=2.10,<3",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.8,<1"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "ragcore_sdk"
|
||||
module-root = "src"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Typed Python client for RAGCore's search/context/answers API."""
|
||||
|
||||
from ragcore_sdk._version import __version__
|
||||
from ragcore_sdk.client import AsyncRAGCoreClient, RAGCoreClient
|
||||
from ragcore_sdk.errors import ProblemDetails, RAGCoreAPIError, RAGCoreConnectionError, RAGCoreError
|
||||
from ragcore_sdk.models import (
|
||||
AnswerabilityStatus,
|
||||
AnswerClaim,
|
||||
AnswerResponse,
|
||||
AnswerStreamEvent,
|
||||
AnswerStreamEventType,
|
||||
Citation,
|
||||
ContextBlock,
|
||||
ContextResponse,
|
||||
SearchFilter,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
SearchScores,
|
||||
TrustLevel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnswerClaim",
|
||||
"AnswerResponse",
|
||||
"AnswerStreamEvent",
|
||||
"AnswerStreamEventType",
|
||||
"AnswerabilityStatus",
|
||||
"AsyncRAGCoreClient",
|
||||
"Citation",
|
||||
"ContextBlock",
|
||||
"ContextResponse",
|
||||
"ProblemDetails",
|
||||
"RAGCoreAPIError",
|
||||
"RAGCoreClient",
|
||||
"RAGCoreConnectionError",
|
||||
"RAGCoreError",
|
||||
"SearchFilter",
|
||||
"SearchResponse",
|
||||
"SearchResult",
|
||||
"SearchScores",
|
||||
"TrustLevel",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Single source of truth for the SDK's own version string.
|
||||
|
||||
A separate module (not `__init__.py`) so `client.py` can import it without
|
||||
a circular import back through the package's own `__init__.py`, which
|
||||
itself imports from `client.py`.
|
||||
"""
|
||||
|
||||
__version__ = "1.2.1"
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Synchronous and asynchronous HTTP clients for RAGCore's real, implemented
|
||||
`/v1/search`, `/v1/context`, and `/v1/answers` routes.
|
||||
|
||||
Retries only ever apply to the non-streaming request path, and only on
|
||||
connection failures or a `503` response -- these three endpoints are
|
||||
read-only queries with no side effects, so replaying the whole request is
|
||||
safe. A `4xx` response is never retried: retrying a request the server has
|
||||
already rejected as invalid or unauthorized cannot succeed. A started SSE
|
||||
stream (`answer_stream`) is never retried by this SDK -- once bytes have
|
||||
been read from a partially-consumed stream, silently restarting it could
|
||||
double-attribute events to the caller, so a failed stream surfaces
|
||||
immediately instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator, Sequence
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from ragcore_sdk._version import __version__
|
||||
from ragcore_sdk.errors import ProblemDetails, RAGCoreAPIError, RAGCoreConnectionError
|
||||
from ragcore_sdk.models import (
|
||||
AnswerResponse,
|
||||
AnswerStreamEvent,
|
||||
AnswerStreamEventType,
|
||||
ContextResponse,
|
||||
SearchFilter,
|
||||
SearchResponse,
|
||||
)
|
||||
|
||||
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)
|
||||
_USER_AGENT = f"ragcore-sdk-python/{__version__}"
|
||||
_RETRYABLE_STATUS = {503}
|
||||
_DEFAULT_MAX_RETRIES = 3
|
||||
_BACKOFF_BASE_SECONDS = 0.5
|
||||
|
||||
|
||||
def _is_retryable(status_code: int) -> bool:
|
||||
return status_code in _RETRYABLE_STATUS
|
||||
|
||||
|
||||
def _search_body(
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID],
|
||||
profile: str,
|
||||
max_results: int,
|
||||
filters: SearchFilter | None,
|
||||
include_debug: bool,
|
||||
max_context_tokens: int | None = None,
|
||||
generation_profile: str | None = None,
|
||||
stream: bool | None = None,
|
||||
) -> dict[str, object]:
|
||||
body: dict[str, object] = {
|
||||
"query": query,
|
||||
"requested_space_ids": [str(space_id) for space_id in requested_space_ids],
|
||||
"profile": profile,
|
||||
"max_results": max_results,
|
||||
"include_debug": include_debug,
|
||||
}
|
||||
if filters is not None:
|
||||
body["filters"] = filters.model_dump(mode="json", exclude_none=True)
|
||||
if max_context_tokens is not None:
|
||||
body["max_context_tokens"] = max_context_tokens
|
||||
if generation_profile is not None:
|
||||
body["generation_profile"] = generation_profile
|
||||
if stream is not None:
|
||||
body["stream"] = stream
|
||||
return body
|
||||
|
||||
|
||||
def _problem_from_response(response: httpx.Response) -> ProblemDetails:
|
||||
try:
|
||||
payload: dict[str, Any] = response.json()
|
||||
except ValueError:
|
||||
payload = {
|
||||
"type": "about:blank",
|
||||
"title": "Error",
|
||||
"status": response.status_code,
|
||||
"code": "NON_PROBLEM_RESPONSE",
|
||||
"trace_id": "",
|
||||
}
|
||||
return ProblemDetails.from_json(payload)
|
||||
|
||||
|
||||
def _raise_for_problem(response: httpx.Response) -> None:
|
||||
if response.status_code < 400:
|
||||
return
|
||||
raise RAGCoreAPIError(_problem_from_response(response))
|
||||
|
||||
|
||||
def _parse_sse_lines(lines: Iterable[str]) -> Iterator[AnswerStreamEvent]:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
for line in lines:
|
||||
if line == "":
|
||||
if event_type is not None:
|
||||
data = json.loads("".join(data_lines)) if data_lines else {}
|
||||
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
|
||||
event_type, data_lines = None, []
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_type = line.removeprefix("event:").strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line.removeprefix("data:").strip())
|
||||
if event_type is not None and data_lines:
|
||||
data = json.loads("".join(data_lines))
|
||||
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
|
||||
|
||||
|
||||
async def _parse_sse_lines_async(lines: AsyncIterable[str]) -> AsyncIterator[AnswerStreamEvent]:
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
async for line in lines:
|
||||
if line == "":
|
||||
if event_type is not None:
|
||||
data = json.loads("".join(data_lines)) if data_lines else {}
|
||||
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
|
||||
event_type, data_lines = None, []
|
||||
continue
|
||||
if line.startswith("event:"):
|
||||
event_type = line.removeprefix("event:").strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line.removeprefix("data:").strip())
|
||||
if event_type is not None and data_lines:
|
||||
data = json.loads("".join(data_lines))
|
||||
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
|
||||
|
||||
|
||||
class RAGCoreClient:
|
||||
"""Synchronous RAGCore client for `/v1/search`, `/v1/context`, `/v1/answers`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
"""`transport` is a test/advanced-usage hook (e.g. `httpx.ASGITransport`
|
||||
to point this client at an in-process app with no real network hop);
|
||||
production callers should leave it unset.
|
||||
"""
|
||||
self._max_retries = max_retries
|
||||
self._client = httpx.Client(
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
headers={"authorization": f"Bearer {token}", "user-agent": _USER_AGENT},
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
def __enter__(self) -> RAGCoreClient:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"RAGCoreClient(base_url={self._client.base_url!r})"
|
||||
|
||||
def _post(self, path: str, body: dict[str, object]) -> httpx.Response:
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
response = self._client.post(path, json=body)
|
||||
except httpx.TransportError as error:
|
||||
if attempt >= self._max_retries:
|
||||
raise RAGCoreConnectionError(type(error).__name__) from error
|
||||
else:
|
||||
if not _is_retryable(response.status_code) or attempt >= self._max_retries:
|
||||
return response
|
||||
time.sleep(_BACKOFF_BASE_SECONDS * (2**attempt))
|
||||
attempt += 1
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
) -> SearchResponse:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
)
|
||||
response = self._post("/v1/search", body)
|
||||
_raise_for_problem(response)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
def context(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
max_context_tokens: int = 8000,
|
||||
) -> ContextResponse:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
max_context_tokens=max_context_tokens,
|
||||
)
|
||||
response = self._post("/v1/context", body)
|
||||
_raise_for_problem(response)
|
||||
return ContextResponse.model_validate(response.json())
|
||||
|
||||
def answer(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
max_context_tokens: int = 8000,
|
||||
generation_profile: str = "answer-local-balanced-v1",
|
||||
) -> AnswerResponse:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
max_context_tokens=max_context_tokens,
|
||||
generation_profile=generation_profile,
|
||||
stream=False,
|
||||
)
|
||||
response = self._post("/v1/answers", body)
|
||||
_raise_for_problem(response)
|
||||
return AnswerResponse.model_validate(response.json())
|
||||
|
||||
def answer_stream(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
max_context_tokens: int = 8000,
|
||||
generation_profile: str = "answer-local-balanced-v1",
|
||||
) -> Iterator[AnswerStreamEvent]:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
max_context_tokens=max_context_tokens,
|
||||
generation_profile=generation_profile,
|
||||
stream=True,
|
||||
)
|
||||
try:
|
||||
with self._client.stream("POST", "/v1/answers", json=body) as response:
|
||||
if response.status_code >= 400:
|
||||
response.read()
|
||||
_raise_for_problem(response)
|
||||
yield from _parse_sse_lines(response.iter_lines())
|
||||
except httpx.TransportError as error:
|
||||
raise RAGCoreConnectionError(type(error).__name__) from error
|
||||
|
||||
|
||||
class AsyncRAGCoreClient:
|
||||
"""Asynchronous RAGCore client for `/v1/search`, `/v1/context`, `/v1/answers`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
"""`transport` is a test/advanced-usage hook (e.g. `httpx.ASGITransport`
|
||||
to point this client at an in-process app with no real network hop);
|
||||
production callers should leave it unset.
|
||||
"""
|
||||
self._max_retries = max_retries
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
headers={"authorization": f"Bearer {token}", "user-agent": _USER_AGENT},
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> AsyncRAGCoreClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> None:
|
||||
await self.aclose()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AsyncRAGCoreClient(base_url={self._client.base_url!r})"
|
||||
|
||||
async def _post(self, path: str, body: dict[str, object]) -> httpx.Response:
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
response = await self._client.post(path, json=body)
|
||||
except httpx.TransportError as error:
|
||||
if attempt >= self._max_retries:
|
||||
raise RAGCoreConnectionError(type(error).__name__) from error
|
||||
else:
|
||||
if not _is_retryable(response.status_code) or attempt >= self._max_retries:
|
||||
return response
|
||||
await asyncio.sleep(_BACKOFF_BASE_SECONDS * (2**attempt))
|
||||
attempt += 1
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
) -> SearchResponse:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
)
|
||||
response = await self._post("/v1/search", body)
|
||||
_raise_for_problem(response)
|
||||
return SearchResponse.model_validate(response.json())
|
||||
|
||||
async def context(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
max_context_tokens: int = 8000,
|
||||
) -> ContextResponse:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
max_context_tokens=max_context_tokens,
|
||||
)
|
||||
response = await self._post("/v1/context", body)
|
||||
_raise_for_problem(response)
|
||||
return ContextResponse.model_validate(response.json())
|
||||
|
||||
async def answer(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
max_context_tokens: int = 8000,
|
||||
generation_profile: str = "answer-local-balanced-v1",
|
||||
) -> AnswerResponse:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
max_context_tokens=max_context_tokens,
|
||||
generation_profile=generation_profile,
|
||||
stream=False,
|
||||
)
|
||||
response = await self._post("/v1/answers", body)
|
||||
_raise_for_problem(response)
|
||||
return AnswerResponse.model_validate(response.json())
|
||||
|
||||
async def answer_stream(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
requested_space_ids: Sequence[UUID] = (),
|
||||
profile: str = "hybrid-v1",
|
||||
max_results: int = 10,
|
||||
filters: SearchFilter | None = None,
|
||||
include_debug: bool = False,
|
||||
max_context_tokens: int = 8000,
|
||||
generation_profile: str = "answer-local-balanced-v1",
|
||||
) -> AsyncIterator[AnswerStreamEvent]:
|
||||
body = _search_body(
|
||||
query,
|
||||
requested_space_ids=requested_space_ids,
|
||||
profile=profile,
|
||||
max_results=max_results,
|
||||
filters=filters,
|
||||
include_debug=include_debug,
|
||||
max_context_tokens=max_context_tokens,
|
||||
generation_profile=generation_profile,
|
||||
stream=True,
|
||||
)
|
||||
try:
|
||||
async with self._client.stream("POST", "/v1/answers", json=body) as response:
|
||||
if response.status_code >= 400:
|
||||
await response.aread()
|
||||
_raise_for_problem(response)
|
||||
async for event in _parse_sse_lines_async(response.aiter_lines()):
|
||||
yield event
|
||||
except httpx.TransportError as error:
|
||||
raise RAGCoreConnectionError(type(error).__name__) from error
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_TIMEOUT", "AsyncRAGCoreClient", "RAGCoreClient"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Error types raised by the RAGCore SDK.
|
||||
|
||||
Every non-2xx response RAGCore returns is an RFC7807 `application/problem+json`
|
||||
body shaped like `ragcore.api.v1.control.schemas.ProblemDetails`. The SDK
|
||||
parses that body once, here, rather than leaving every call site to
|
||||
re-parse raw JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RAGCoreError(Exception):
|
||||
"""Base class for every error this SDK raises."""
|
||||
|
||||
|
||||
class RAGCoreConnectionError(RAGCoreError):
|
||||
"""A request never got a response, even after exhausting retries.
|
||||
|
||||
Never carries request headers or the auth token in its message.
|
||||
"""
|
||||
|
||||
|
||||
class ProblemDetails:
|
||||
"""Parsed RFC7807 problem body. Deliberately a plain object, not a
|
||||
pydantic model that could accidentally echo unexpected extra fields
|
||||
(a server-side bug) back into a log line via `repr`.
|
||||
"""
|
||||
|
||||
__slots__ = ("code", "detail", "errors", "status", "title", "trace_id", "type")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
type: str,
|
||||
title: str,
|
||||
status: int,
|
||||
code: str,
|
||||
trace_id: str,
|
||||
detail: str | None = None,
|
||||
errors: list[dict[str, str]] | None = None,
|
||||
) -> None:
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.trace_id = trace_id
|
||||
self.detail = detail
|
||||
self.errors = errors or []
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, body: dict[str, Any]) -> ProblemDetails:
|
||||
return cls(
|
||||
type=str(body.get("type", "about:blank")),
|
||||
title=str(body.get("title", "Error")),
|
||||
status=int(body.get("status", 0)),
|
||||
code=str(body.get("code", "UNKNOWN")),
|
||||
trace_id=str(body.get("trace_id", "")),
|
||||
detail=body.get("detail"),
|
||||
errors=body.get("errors"),
|
||||
)
|
||||
|
||||
|
||||
class RAGCoreAPIError(RAGCoreError):
|
||||
"""RAGCore answered with a non-2xx `application/problem+json` response."""
|
||||
|
||||
def __init__(self, problem: ProblemDetails) -> None:
|
||||
self.problem = problem
|
||||
super().__init__(f"{problem.status} {problem.code}: {problem.detail or problem.title}")
|
||||
|
||||
|
||||
__all__ = ["ProblemDetails", "RAGCoreAPIError", "RAGCoreConnectionError", "RAGCoreError"]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Typed request/response models mirroring RAGCore's real `/v1/{search,context,answers}`
|
||||
Pydantic schemas (`ragcore.api.v1.search.schemas`, `.context.schemas`, `.answers.schemas`).
|
||||
|
||||
Kept in lockstep with the server by hand (this SDK is handwritten, not generated) --
|
||||
field names and shapes here must match those modules exactly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class TrustLevel(StrEnum):
|
||||
TRUSTED_INTERNAL = "trusted_internal"
|
||||
TRUSTED_EXTERNAL = "trusted_external"
|
||||
UNVERIFIED = "unverified"
|
||||
USER_UPLOADED = "user_uploaded"
|
||||
INTERNET = "internet"
|
||||
|
||||
|
||||
class AnswerabilityStatus(StrEnum):
|
||||
ANSWERABLE = "answerable"
|
||||
PARTIALLY_ANSWERABLE = "partially_answerable"
|
||||
NOT_ANSWERABLE = "not_answerable"
|
||||
CONFLICTING_EVIDENCE = "conflicting_evidence"
|
||||
|
||||
|
||||
class SearchFilter(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content_types: list[str] | None = None
|
||||
languages: list[str] | None = None
|
||||
source_ids: list[UUID] | None = None
|
||||
document_ids: list[UUID] | None = None
|
||||
trust_levels: list[TrustLevel] | None = None
|
||||
|
||||
|
||||
class Citation(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: UUID
|
||||
document_id: UUID
|
||||
document_version_id: UUID
|
||||
title: str
|
||||
section: str | None = None
|
||||
page_start: int | None = None
|
||||
page_end: int | None = None
|
||||
line_start: int | None = None
|
||||
line_end: int | None = None
|
||||
excerpt: str
|
||||
excerpt_sha256: str
|
||||
source_uri: str
|
||||
# Provenance presentation fields (RAGCore >= 1.2.0); optional so the client
|
||||
# still parses responses from older servers.
|
||||
source_id: UUID | None = None
|
||||
source_name: str | None = None
|
||||
source_kind: str | None = None
|
||||
locator: str | None = None
|
||||
revision: str | None = None
|
||||
display: str | None = None
|
||||
|
||||
|
||||
class SearchScores(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dense: float | None = None
|
||||
sparse: float | None = None
|
||||
fused: float | None = None
|
||||
rerank: float | None = None
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
chunk_id: UUID
|
||||
parent_chunk_id: UUID | None = None
|
||||
text: str
|
||||
rank: int
|
||||
scores: SearchScores
|
||||
citation: Citation
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
retrieval_run_id: UUID
|
||||
effective_space_ids: list[UUID]
|
||||
results: list[SearchResult]
|
||||
degraded: bool
|
||||
degradation_codes: list[str] = Field(default_factory=list)
|
||||
timings_ms: dict[str, float] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ContextBlock(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
text: str
|
||||
citation_ids: list[UUID]
|
||||
token_count: int
|
||||
|
||||
|
||||
class ContextResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
retrieval_run_id: UUID
|
||||
answerability: AnswerabilityStatus
|
||||
answerability_reasons: list[str] = Field(default_factory=list)
|
||||
blocks: list[ContextBlock] = Field(default_factory=list)
|
||||
citations: list[Citation] = Field(default_factory=list)
|
||||
token_count: int
|
||||
degraded: bool = False
|
||||
degradation_reasons: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AnswerClaim(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
text: str
|
||||
citation_ids: list[UUID]
|
||||
|
||||
|
||||
class AnswerResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
answer_id: UUID
|
||||
retrieval_run_id: UUID
|
||||
answerability: AnswerabilityStatus
|
||||
answer: str
|
||||
citations: list[Citation]
|
||||
claims: list[AnswerClaim]
|
||||
degraded: bool = False
|
||||
degradation_reasons: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AnswerStreamEventType(StrEnum):
|
||||
RETRIEVAL_METADATA = "retrieval_metadata"
|
||||
DONE = "done"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class AnswerStreamEvent(BaseModel):
|
||||
"""One parsed SSE frame from a streaming `/v1/answers` call.
|
||||
|
||||
`data` is the raw decoded JSON payload -- its shape depends on `type`
|
||||
(see `ragcore.api.v1.answers.service._metadata_event/_done_event/_error_event`
|
||||
for the three real shapes). Use `as_done_answer()` for a typed view of a
|
||||
`done` event rather than re-parsing `data` by hand.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: AnswerStreamEventType
|
||||
data: dict[str, Any]
|
||||
|
||||
def as_done_answer(self) -> AnswerResponse | None:
|
||||
if self.type is not AnswerStreamEventType.DONE:
|
||||
return None
|
||||
return AnswerResponse.model_validate(self.data)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AnswerClaim",
|
||||
"AnswerResponse",
|
||||
"AnswerStreamEvent",
|
||||
"AnswerStreamEventType",
|
||||
"AnswerabilityStatus",
|
||||
"Citation",
|
||||
"ContextBlock",
|
||||
"ContextResponse",
|
||||
"SearchFilter",
|
||||
"SearchResponse",
|
||||
"SearchResult",
|
||||
"SearchScores",
|
||||
"TrustLevel",
|
||||
]
|
||||
Whitespace-only changes.
Whitespace-only changes.
Generated
+1681
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@ragcore/sdk",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"description": "Typed TypeScript client for RAGCore's search/context/answers API",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.ts",
|
||||
"main": "./dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* A single client for RAGCore's real, implemented `/v1/search`,
|
||||
* `/v1/context`, and `/v1/answers` routes. Uses global `fetch`, so it works
|
||||
* unmodified in a browser or in Node (18+) -- no Node-only APIs.
|
||||
*
|
||||
* Unlike the Python SDK there's no separate sync/async pair: `fetch` is
|
||||
* inherently async in both browser and Node, so one class covers both.
|
||||
*
|
||||
* Retries only ever apply to the non-streaming request path, and only on a
|
||||
* network failure or a `503` response -- these three endpoints are
|
||||
* read-only queries with no side effects, so replaying the whole request
|
||||
* is safe. A `4xx` response is never retried. A started SSE stream
|
||||
* (`answerStream`) is never retried by this SDK: once bytes have been read
|
||||
* from a partially-consumed stream, silently restarting it could
|
||||
* double-attribute events to the caller, so a failed stream surfaces
|
||||
* immediately instead.
|
||||
*
|
||||
* The bearer token is held only in a true private class field (`#token`,
|
||||
* not TypeScript's compile-time-only `private`), so it is never
|
||||
* JSON-serializable, enumerable, or visible via `console.log`/spread on the
|
||||
* client instance, and it is never written to `localStorage`,
|
||||
* `sessionStorage`, or a cookie by this SDK.
|
||||
*/
|
||||
|
||||
import { problemFromJson, RAGCoreAPIError, RAGCoreConnectionError } from "./errors.js";
|
||||
import type {
|
||||
AnswerResponse,
|
||||
AnswerStreamEvent,
|
||||
AnswerStreamEventType,
|
||||
ContextResponse,
|
||||
SearchFilter,
|
||||
SearchResponse,
|
||||
} from "./models.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const BACKOFF_BASE_MS = 500;
|
||||
const RETRYABLE_STATUS = new Set([503]);
|
||||
// Kept in sync with package.json's own "version" by hand (a small,
|
||||
// hand-maintained SDK, not worth a build-time version-injection step).
|
||||
// A custom header, not `User-Agent`: browsers silently drop attempts to
|
||||
// set `User-Agent` via fetch, so it would work in Node but not browsers.
|
||||
const _SDK_VERSION = "1.2.1";
|
||||
|
||||
export interface RAGCoreClientOptions {
|
||||
timeoutMs?: number;
|
||||
maxRetries?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
interface QueryOptions {
|
||||
requestedSpaceIds?: string[];
|
||||
profile?: string;
|
||||
maxResults?: number;
|
||||
filters?: SearchFilter;
|
||||
includeDebug?: boolean;
|
||||
}
|
||||
|
||||
interface ContextOptions extends QueryOptions {
|
||||
maxContextTokens?: number;
|
||||
}
|
||||
|
||||
interface AnswerOptions extends ContextOptions {
|
||||
generationProfile?: string;
|
||||
}
|
||||
|
||||
function searchBody(
|
||||
query: string,
|
||||
opts: QueryOptions & { maxContextTokens?: number; generationProfile?: string; stream?: boolean }
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
query,
|
||||
requested_space_ids: opts.requestedSpaceIds ?? [],
|
||||
profile: opts.profile ?? "hybrid-v1",
|
||||
max_results: opts.maxResults ?? 10,
|
||||
include_debug: opts.includeDebug ?? false,
|
||||
};
|
||||
if (opts.filters !== undefined) {
|
||||
body["filters"] = opts.filters;
|
||||
}
|
||||
if (opts.maxContextTokens !== undefined) {
|
||||
body["max_context_tokens"] = opts.maxContextTokens;
|
||||
}
|
||||
if (opts.generationProfile !== undefined) {
|
||||
body["generation_profile"] = opts.generationProfile;
|
||||
}
|
||||
if (opts.stream !== undefined) {
|
||||
body["stream"] = opts.stream;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return RETRYABLE_STATUS.has(status);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function problemFromResponse(response: Response): Promise<ReturnType<typeof problemFromJson>> {
|
||||
try {
|
||||
return problemFromJson(await response.json());
|
||||
} catch {
|
||||
return problemFromJson({
|
||||
type: "about:blank",
|
||||
title: "Error",
|
||||
status: response.status,
|
||||
code: "NON_PROBLEM_RESPONSE",
|
||||
trace_id: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function raiseForProblem(response: Response): Promise<void> {
|
||||
if (response.status < 400) {
|
||||
return;
|
||||
}
|
||||
throw new RAGCoreAPIError(await problemFromResponse(response));
|
||||
}
|
||||
|
||||
async function* parseSseLines(lines: AsyncIterable<string>): AsyncGenerator<AnswerStreamEvent> {
|
||||
let eventType: string | null = null;
|
||||
let dataLines: string[] = [];
|
||||
for await (const line of lines) {
|
||||
if (line === "") {
|
||||
if (eventType !== null) {
|
||||
const data = dataLines.length > 0 ? (JSON.parse(dataLines.join("")) as Record<string, unknown>) : {};
|
||||
yield { type: eventType as AnswerStreamEventType, data };
|
||||
}
|
||||
eventType = null;
|
||||
dataLines = [];
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("event:")) {
|
||||
eventType = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trim());
|
||||
}
|
||||
}
|
||||
if (eventType !== null && dataLines.length > 0) {
|
||||
const data = JSON.parse(dataLines.join("")) as Record<string, unknown>;
|
||||
yield { type: eventType as AnswerStreamEventType, data };
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits a byte stream into text lines, the way `iter_lines`/`aiter_lines` do. */
|
||||
async function* streamLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
|
||||
yield buffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
}
|
||||
}
|
||||
if (buffer.length > 0) {
|
||||
yield buffer;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export class RAGCoreClient {
|
||||
readonly #baseUrl: string;
|
||||
readonly #token: string;
|
||||
readonly #fetchImpl: typeof fetch;
|
||||
readonly #timeoutMs: number;
|
||||
readonly #maxRetries: number;
|
||||
|
||||
constructor(baseUrl: string, token: string, options: RAGCoreClientOptions = {}) {
|
||||
this.#baseUrl = baseUrl.replace(/\/+$/, "");
|
||||
this.#token = token;
|
||||
this.#fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.#maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `RAGCoreClient(baseUrl=${this.#baseUrl})`;
|
||||
}
|
||||
|
||||
async #post(path: string, body: Record<string, unknown>): Promise<Response> {
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
|
||||
try {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.#fetchImpl(`${this.#baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${this.#token}`,
|
||||
"x-ragcore-sdk-version": _SDK_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (attempt >= this.#maxRetries) {
|
||||
throw new RAGCoreConnectionError(error);
|
||||
}
|
||||
await sleep(BACKOFF_BASE_MS * 2 ** attempt);
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isRetryableStatus(response.status) || attempt >= this.#maxRetries) {
|
||||
return response;
|
||||
}
|
||||
await sleep(BACKOFF_BASE_MS * 2 ** attempt);
|
||||
attempt += 1;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, opts: QueryOptions = {}): Promise<SearchResponse> {
|
||||
const response = await this.#post("/v1/search", searchBody(query, opts));
|
||||
await raiseForProblem(response);
|
||||
return (await response.json()) as SearchResponse;
|
||||
}
|
||||
|
||||
async context(query: string, opts: ContextOptions = {}): Promise<ContextResponse> {
|
||||
const response = await this.#post("/v1/context", searchBody(query, opts));
|
||||
await raiseForProblem(response);
|
||||
return (await response.json()) as ContextResponse;
|
||||
}
|
||||
|
||||
async answer(query: string, opts: AnswerOptions = {}): Promise<AnswerResponse> {
|
||||
const response = await this.#post("/v1/answers", searchBody(query, { ...opts, stream: false }));
|
||||
await raiseForProblem(response);
|
||||
return (await response.json()) as AnswerResponse;
|
||||
}
|
||||
|
||||
async *answerStream(query: string, opts: AnswerOptions = {}): AsyncGenerator<AnswerStreamEvent> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.#fetchImpl(`${this.#baseUrl}/v1/answers`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${this.#token}`,
|
||||
"x-ragcore-sdk-version": _SDK_VERSION,
|
||||
},
|
||||
body: JSON.stringify(searchBody(query, { ...opts, stream: true })),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new RAGCoreConnectionError(error);
|
||||
}
|
||||
if (response.status >= 400) {
|
||||
await raiseForProblem(response);
|
||||
}
|
||||
if (response.body === null) {
|
||||
return;
|
||||
}
|
||||
yield* parseSseLines(streamLines(response.body));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Error types raised by the RAGCore SDK.
|
||||
*
|
||||
* Every non-2xx response RAGCore returns is an RFC7807
|
||||
* `application/problem+json` body shaped like
|
||||
* `ragcore.api.v1.control.schemas.ProblemDetails`. The SDK parses that body
|
||||
* once, here, rather than leaving every call site to re-parse raw JSON.
|
||||
*/
|
||||
|
||||
export interface ProblemDetails {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
code: string;
|
||||
detail: string | null;
|
||||
trace_id: string;
|
||||
errors: Array<Record<string, string>>;
|
||||
}
|
||||
|
||||
export function problemFromJson(body: unknown): ProblemDetails {
|
||||
const record = typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
|
||||
return {
|
||||
type: typeof record["type"] === "string" ? record["type"] : "about:blank",
|
||||
title: typeof record["title"] === "string" ? record["title"] : "Error",
|
||||
status: typeof record["status"] === "number" ? record["status"] : 0,
|
||||
code: typeof record["code"] === "string" ? record["code"] : "UNKNOWN",
|
||||
detail: typeof record["detail"] === "string" ? record["detail"] : null,
|
||||
trace_id: typeof record["trace_id"] === "string" ? record["trace_id"] : "",
|
||||
errors: Array.isArray(record["errors"]) ? (record["errors"] as Array<Record<string, string>>) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Base class for every error this SDK raises. */
|
||||
export class RAGCoreError extends Error {}
|
||||
|
||||
/**
|
||||
* A request never got a response, even after exhausting retries. Never
|
||||
* carries request headers or the auth token in its message.
|
||||
*/
|
||||
export class RAGCoreConnectionError extends RAGCoreError {
|
||||
constructor(cause: unknown) {
|
||||
super(`connection failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
||||
this.name = "RAGCoreConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** RAGCore answered with a non-2xx `application/problem+json` response. */
|
||||
export class RAGCoreAPIError extends RAGCoreError {
|
||||
readonly problem: ProblemDetails;
|
||||
|
||||
constructor(problem: ProblemDetails) {
|
||||
super(`${problem.status} ${problem.code}: ${problem.detail ?? problem.title}`);
|
||||
this.name = "RAGCoreAPIError";
|
||||
this.problem = problem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export { RAGCoreClient } from "./client.js";
|
||||
export type { RAGCoreClientOptions } from "./client.js";
|
||||
export { RAGCoreAPIError, RAGCoreConnectionError, RAGCoreError } from "./errors.js";
|
||||
export type { ProblemDetails } from "./errors.js";
|
||||
export { asDoneAnswer } from "./models.js";
|
||||
export type {
|
||||
AnswerClaim,
|
||||
AnswerResponse,
|
||||
AnswerStreamEvent,
|
||||
AnswerStreamEventType,
|
||||
AnswerabilityStatus,
|
||||
Citation,
|
||||
ContextBlock,
|
||||
ContextResponse,
|
||||
SearchFilter,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
SearchScores,
|
||||
TrustLevel,
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Typed request/response models mirroring RAGCore's real
|
||||
* `/v1/{search,context,answers}` Pydantic schemas
|
||||
* (`ragcore.api.v1.search.schemas`, `.context.schemas`, `.answers.schemas`).
|
||||
*
|
||||
* Kept in lockstep with the server by hand (this SDK is handwritten, not
|
||||
* generated) -- field names and shapes here must match those modules
|
||||
* exactly. UUIDs and timestamps are plain `string` (the JSON wire shape),
|
||||
* not wrapped types, to keep this dependency-free and browser-safe.
|
||||
*/
|
||||
|
||||
export type TrustLevel =
|
||||
| "trusted_internal"
|
||||
| "trusted_external"
|
||||
| "unverified"
|
||||
| "user_uploaded"
|
||||
| "internet";
|
||||
|
||||
export type AnswerabilityStatus =
|
||||
| "answerable"
|
||||
| "partially_answerable"
|
||||
| "not_answerable"
|
||||
| "conflicting_evidence";
|
||||
|
||||
export interface SearchFilter {
|
||||
content_types?: string[];
|
||||
languages?: string[];
|
||||
source_ids?: string[];
|
||||
document_ids?: string[];
|
||||
trust_levels?: TrustLevel[];
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
id: string;
|
||||
document_id: string;
|
||||
document_version_id: string;
|
||||
title: string;
|
||||
section: string | null;
|
||||
page_start: number | null;
|
||||
page_end: number | null;
|
||||
line_start: number | null;
|
||||
line_end: number | null;
|
||||
excerpt: string;
|
||||
excerpt_sha256: string;
|
||||
source_uri: string;
|
||||
/** Provenance presentation (RAGCore >= 1.2.0); absent on older servers. */
|
||||
source_id?: string | null;
|
||||
source_name?: string | null;
|
||||
source_kind?: "upload" | "folder" | "git" | "website" | null;
|
||||
locator?: string | null;
|
||||
revision?: string | null;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
export interface SearchScores {
|
||||
dense: number | null;
|
||||
sparse: number | null;
|
||||
fused: number | null;
|
||||
rerank: number | null;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
chunk_id: string;
|
||||
parent_chunk_id: string | null;
|
||||
text: string;
|
||||
rank: number;
|
||||
scores: SearchScores;
|
||||
citation: Citation;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
retrieval_run_id: string;
|
||||
effective_space_ids: string[];
|
||||
results: SearchResult[];
|
||||
degraded: boolean;
|
||||
degradation_codes: string[];
|
||||
timings_ms: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ContextBlock {
|
||||
text: string;
|
||||
citation_ids: string[];
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
export interface ContextResponse {
|
||||
retrieval_run_id: string;
|
||||
answerability: AnswerabilityStatus;
|
||||
answerability_reasons: string[];
|
||||
blocks: ContextBlock[];
|
||||
citations: Citation[];
|
||||
token_count: number;
|
||||
degraded: boolean;
|
||||
degradation_reasons: string[];
|
||||
}
|
||||
|
||||
export interface AnswerClaim {
|
||||
text: string;
|
||||
citation_ids: string[];
|
||||
}
|
||||
|
||||
export interface AnswerResponse {
|
||||
answer_id: string;
|
||||
retrieval_run_id: string;
|
||||
answerability: AnswerabilityStatus;
|
||||
answer: string;
|
||||
citations: Citation[];
|
||||
claims: AnswerClaim[];
|
||||
degraded: boolean;
|
||||
degradation_reasons: string[];
|
||||
}
|
||||
|
||||
export type AnswerStreamEventType = "retrieval_metadata" | "done" | "error";
|
||||
|
||||
/**
|
||||
* One parsed SSE frame from a streaming `/v1/answers` call. `data` is the
|
||||
* raw decoded JSON payload -- its shape depends on `type` (see
|
||||
* `ragcore.api.v1.answers.service._metadata_event/_done_event/_error_event`
|
||||
* for the three real shapes). Use `asDoneAnswer()` for a typed view of a
|
||||
* `done` event rather than re-parsing `data` by hand.
|
||||
*/
|
||||
export interface AnswerStreamEvent {
|
||||
type: AnswerStreamEventType;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asDoneAnswer(event: AnswerStreamEvent): AnswerResponse | null {
|
||||
if (event.type !== "done") {
|
||||
return null;
|
||||
}
|
||||
return event.data as unknown as AnswerResponse;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["../../tests/contract/sdk/typescript/**/*.test.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user