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.
Reference in new issue
Block a user