Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:21 +02:00
commit 7ca726caf2
659 files changed
+98171

No files matched your search

+399
View File
@@ -0,0 +1,399 @@
"""Fixture FastAPI apps for the SDK contract tests.
Mirrors the same fixture-construction pattern used by the existing
`tests/contract/api/{search,context,answers}` contract tests (real
`RetrievalAuthorizationService`, a fixture executor/generator standing in
only for the retrieval backend/model provider) -- these tests exist to
prove the SDK's HTTP layer (request shape, response typing, SSE parsing,
Problem Details mapping, retries), not to re-prove authorization/retrieval
business logic that is already covered there.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import re
import socket
import sys
import threading
import time
from collections.abc import Iterator
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid4
import uvicorn
from fastapi import FastAPI
from ragcore.api.v1.answers.dependencies import get_answer_application, get_answer_principal
from ragcore.api.v1.answers.service import AnswerService
from ragcore.api.v1.context.dependencies import get_context_application, get_context_principal
from ragcore.api.v1.context.service import (
ContextExecutionRequest,
ContextExecutionResult,
ContextService,
)
from ragcore.api.v1.search.dependencies import get_search_application, get_search_principal
from ragcore.api.v1.search.service import SearchHit, SearchQuery, SearchService
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
from ragcore.domain.authorization import Principal, PrincipalType
from ragcore.domain.citations import Citation
from ragcore.domain.control import Application, Role, Workspace
from ragcore.main import create_app
from ragcore.retrieval.context import ContextCandidate
from ragcore.retrieval.ranking import FusedCandidate
from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance
EVIDENCE_EXCERPT = "The refund window is thirty days from the original purchase date."
class _FixtureInputs:
def __init__(self, workspace: Workspace, space_id: UUID, space_name: str) -> None:
self.workspace = workspace
self.space_id = space_id
self.space_name = space_name
async def load(self, principal: Principal) -> Any:
space = self.workspace.create_knowledge_space(
id=self.space_id, name=self.space_name, slug=self.space_name.lower().replace(" ", "-")
)
return type(
"Inputs",
(),
{
"spaces": (space,),
"grants": (),
"user_grants": (
self.workspace.grant_user(
id=uuid4(), user_id=principal.id, knowledge_space=space, role=Role.EDITOR
),
),
"application": Application(
id=uuid4(),
workspace_id=self.workspace.id,
name=f"{self.space_name} application",
default_knowledge_space_ids=frozenset({self.space_id}),
),
},
)()
@dataclass
class SearchFixture:
app: FastAPI
principal: Principal
space_id: UUID
calls: list[SearchQuery]
def build_search_app() -> SearchFixture:
workspace = Workspace(uuid4(), "SDK search workspace", "sdk-search-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.VIEWER}))
document_id, version_id, chunk_id = uuid4(), uuid4(), uuid4()
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=document_id,
document_version_id=version_id,
chunk_id=chunk_id,
parent_chunk_id=None,
location={"page_start": 4, "page_end": 4, "section": "Refunds"},
excerpt=EVIDENCE_EXCERPT,
source_uri="https://example.test/policy.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = Candidate(
point_id=uuid4(),
score=0.87,
rank=1,
backend=CandidateBackendKind.DENSE,
provenance=CandidateProvenance(
workspace_id=workspace.id,
space_id=space_id,
document_id=document_id,
document_version_id=version_id,
chunk_id=chunk_id,
source_id=uuid4(),
parent_chunk_id=None,
status="published",
content_type="text/markdown",
language="en",
trust_level="trusted_internal",
),
)
hit = SearchHit(
fused=FusedCandidate(candidate, 1 / 61, 1, None),
text=EVIDENCE_EXCERPT,
title="Refund policy",
citation=citation,
dense_score=0.87,
)
calls: list[SearchQuery] = []
class _Executor:
async def execute(self, request: Any) -> Any:
refreshed = replace(
hit,
citation=Citation.create(
workspace_id=hit.citation.workspace_id,
space_id=hit.citation.space_id,
document_id=hit.citation.document_id,
document_version_id=hit.citation.document_version_id,
chunk_id=hit.citation.chunk_id,
parent_chunk_id=hit.citation.parent_chunk_id,
location=hit.citation.location,
excerpt=hit.citation.excerpt,
source_uri=hit.citation.source_uri,
acquired_at=hit.citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=hit.citation.index_alias,
profile_version=hit.citation.profile_version,
),
)
return type(
"Result",
(),
{
"hits": (refreshed,),
"degraded": False,
"degradation_codes": (),
"timings_ms": {},
},
)()
application = Application(
id=uuid4(),
workspace_id=workspace.id,
name="SDK search application",
default_knowledge_space_ids=frozenset({space_id}),
)
class _Inputs(_FixtureInputs):
async def load(self, principal: Principal) -> Any:
base = await super().load(principal)
return type(
"Inputs",
(),
{
"spaces": base.spaces,
"grants": base.grants,
"user_grants": base.user_grants,
"application": application,
},
)()
service = SearchService(
authorization=RetrievalAuthorizationService(),
inputs=_Inputs(workspace, space_id, "SDK search space"),
executor=_Executor(),
)
app = create_app()
app.dependency_overrides[get_search_principal] = lambda: principal
app.dependency_overrides[get_search_application] = lambda: service
return SearchFixture(app=app, principal=principal, space_id=space_id, calls=calls)
@dataclass
class ContextFixture:
app: FastAPI
principal: Principal
space_id: UUID
def build_context_app() -> ContextFixture:
workspace = Workspace(uuid4(), "SDK context workspace", "sdk-context-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=uuid4(),
parent_chunk_id=None,
location={"page_start": 1},
excerpt=EVIDENCE_EXCERPT,
source_uri="https://example.test/context.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0)
class _Executor:
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
refreshed = Citation.create(
workspace_id=citation.workspace_id,
space_id=citation.space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
parent_chunk_id=citation.parent_chunk_id,
location=citation.location,
excerpt=citation.excerpt,
source_uri=citation.source_uri,
acquired_at=citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=citation.index_alias,
profile_version=citation.profile_version,
)
return ContextExecutionResult(candidates=(replace(candidate, citation=refreshed),))
service = ContextService(
authorization=RetrievalAuthorizationService(),
inputs=_FixtureInputs(workspace, space_id, "SDK context space"),
executor=_Executor(),
)
app = create_app()
app.dependency_overrides[get_context_principal] = lambda: principal
app.dependency_overrides[get_context_application] = lambda: service
return ContextFixture(app=app, principal=principal, space_id=space_id)
_CITATION_ID = re.compile(r"citation_ids=([0-9a-fA-F-]{36})")
@dataclass
class AnswerFixture:
app: FastAPI
principal: Principal
space_id: UUID
def build_answer_app(*, generator_responses: list[str | None] | None = None) -> AnswerFixture:
workspace = Workspace(uuid4(), "SDK answers workspace", "sdk-answers-workspace")
space_id = uuid4()
principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR}))
citation = Citation.create(
workspace_id=workspace.id,
space_id=space_id,
document_id=uuid4(),
document_version_id=uuid4(),
chunk_id=uuid4(),
parent_chunk_id=None,
location={"page_start": 1},
excerpt=EVIDENCE_EXCERPT,
source_uri="https://example.test/answers.md",
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
retrieval_run_id=uuid4(),
index_alias="rag_dense_hybrid_active",
profile_version="hybrid-v1",
)
candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0)
class _ContextExecutor:
async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult:
refreshed = Citation.create(
workspace_id=citation.workspace_id,
space_id=citation.space_id,
document_id=citation.document_id,
document_version_id=citation.document_version_id,
chunk_id=citation.chunk_id,
parent_chunk_id=citation.parent_chunk_id,
location=citation.location,
excerpt=citation.excerpt,
source_uri=citation.source_uri,
acquired_at=citation.acquired_at,
retrieval_run_id=request.retrieval_run_id,
index_alias=citation.index_alias,
profile_version=citation.profile_version,
)
return ContextExecutionResult(candidates=(replace(candidate, citation=refreshed),))
responses = list(generator_responses if generator_responses is not None else [None])
class _Generator:
async def generate(self, *, prompt: str, generation_profile: str, spaces: Any) -> str:
response = responses.pop(0) if responses else None
if response is not None:
return response
match = _CITATION_ID.search(prompt)
assert match is not None
return json.dumps(
{
"status": "answerable",
"answer": "The refund window is thirty days from purchase.",
"citations": [match.group(1)],
}
)
service = AnswerService(
authorization=RetrievalAuthorizationService(),
inputs=_FixtureInputs(workspace, space_id, "SDK answers space"),
context_executor=_ContextExecutor(),
generator=_Generator(),
)
app = create_app()
app.dependency_overrides[get_answer_principal] = lambda: principal
app.dependency_overrides[get_answer_application] = lambda: service
return AnswerFixture(app=app, principal=principal, space_id=space_id)
def _serve_forever(server: uvicorn.Server, sock: socket.socket) -> None:
# Deliberately not `server.run()`: that wraps `server.serve()` in
# `asyncio.run(..., loop_factory=...)`, and uvicorn's own loop factory
# (`uvicorn.loops.asyncio.asyncio_loop_factory`) hardcodes
# `asyncio.ProactorEventLoop` on Windows -- which the real app's
# lifespan (an async psycopg connection) refuses to run under, no
# matter what global event-loop policy is set beforehand. Driving the
# loop directly sidesteps uvicorn's own hardcoded choice.
loop = asyncio.SelectorEventLoop() if sys.platform == "win32" else asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(server.serve(sockets=[sock]))
finally:
loop.close()
@contextlib.contextmanager
def run_app(app: FastAPI) -> Iterator[str]:
"""Serve `app` over a real local TCP socket for the duration of the block.
`RAGCoreClient` (sync) needs this rather than `httpx.ASGITransport`:
ASGITransport only implements httpx's *async* transport interface, so a
sync `httpx.Client` can't use it at all (`AsyncRAGCoreClient` tests use
ASGITransport directly instead, which is the supported combination).
"""
# Hand uvicorn the already-bound socket itself, rather than closing it
# and passing the port number for uvicorn to rebind: closing and
# rebinding is a real race (another process, or even this same
# process's next bind, can win the port first), which is what
# produced Windows `STARTUP_FAILURE`s here in practice.
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen(128)
port = sock.getsockname()[1]
config = uvicorn.Config(app, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=_serve_forever, args=(server, sock), daemon=True)
thread.start()
deadline = time.monotonic() + 10
while not server.started and time.monotonic() < deadline:
time.sleep(0.01)
try:
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
thread.join(timeout=10)
with contextlib.suppress(OSError):
sock.close()
__all__ = [
"AnswerFixture",
"ContextFixture",
"SearchFixture",
"build_answer_app",
"build_context_app",
"build_search_app",
"run_app",
]
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from ragcore_sdk import AnswerStreamEventType, RAGCoreClient
from conftest import build_answer_app, run_app
@contextmanager
def _client(app: object) -> Iterator[RAGCoreClient]:
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token="sdk-test-token") as client: # type: ignore[arg-type] # noqa: E501
yield client
def test_answer_sync_returns_typed_response() -> None:
fixture = build_answer_app()
with _client(fixture.app) as client:
response = client.answer(query="refund window", requested_space_ids=[fixture.space_id])
assert response.answer
assert response.citations
assert all(claim.citation_ids for claim in response.claims)
def test_answer_stream_emits_metadata_then_done_in_order() -> None:
fixture = build_answer_app()
with _client(fixture.app) as client:
events = list(
client.answer_stream(query="refund window", requested_space_ids=[fixture.space_id])
)
assert [event.type for event in events] == [
AnswerStreamEventType.RETRIEVAL_METADATA,
AnswerStreamEventType.DONE,
]
done_answer = events[1].as_done_answer()
assert done_answer is not None
assert done_answer.citations
assert "refund" in done_answer.answer.lower()
assert events[0].as_done_answer() is None
def test_answer_stream_reports_terminal_error_event_on_invalid_generation() -> None:
fixture = build_answer_app(generator_responses=["not json", "still not json"])
with _client(fixture.app) as client:
events = list(
client.answer_stream(query="refund window", requested_space_ids=[fixture.space_id])
)
assert [event.type for event in events] == [
AnswerStreamEventType.RETRIEVAL_METADATA,
AnswerStreamEventType.ERROR,
]
assert events[1].data["code"] == "validation_retries_exhausted"
@@ -0,0 +1,39 @@
from __future__ import annotations
import httpx
from ragcore_sdk import AnswerStreamEventType, AsyncRAGCoreClient
from conftest import build_answer_app, build_search_app
def _async_client(app: object) -> AsyncRAGCoreClient:
return AsyncRAGCoreClient(
base_url="http://testserver",
token="sdk-test-token",
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
)
async def test_async_search_returns_typed_results() -> None:
fixture = build_search_app()
async with _async_client(fixture.app) as client:
response = await client.search(query="refund", requested_space_ids=[fixture.space_id])
assert response.results[0].citation.excerpt_sha256
async def test_async_answer_stream_emits_metadata_then_done() -> None:
fixture = build_answer_app()
async with _async_client(fixture.app) as client:
events = [
event
async for event in client.answer_stream(
query="refund window", requested_space_ids=[fixture.space_id]
)
]
assert [event.type for event in events] == [
AnswerStreamEventType.RETRIEVAL_METADATA,
AnswerStreamEventType.DONE,
]
assert events[1].as_done_answer() is not None
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import pytest
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient
from conftest import build_context_app, run_app
@contextmanager
def _client(app: object) -> Iterator[RAGCoreClient]:
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token="sdk-test-token") as client: # type: ignore[arg-type] # noqa: E501
yield client
def test_context_returns_typed_blocks_and_citations() -> None:
fixture = build_context_app()
with _client(fixture.app) as client:
response = client.context(query="refund", requested_space_ids=[fixture.space_id])
assert response.answerability.value in {
"answerable",
"partially_answerable",
"not_answerable",
"conflicting_evidence",
}
assert response.token_count >= 0
if response.blocks:
assert response.blocks[0].citation_ids
assert response.citations
def test_context_token_budget_below_minimum_raises_typed_problem() -> None:
fixture = build_context_app()
with _client(fixture.app) as client, pytest.raises(RAGCoreAPIError) as excinfo:
client.context(
query="refund",
requested_space_ids=[fixture.space_id],
max_context_tokens=1,
)
problem = excinfo.value.problem
assert problem.status == 422
assert problem.code == "VALIDATION_ERROR"
+108
View File
@@ -0,0 +1,108 @@
"""Retry behavior: only ever on connection failures or a `503`, never on a
`4xx`, and never inside a started SSE stream. See `ragcore_sdk.client`'s
module docstring for the reasoning.
"""
from __future__ import annotations
import httpx
import pytest
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient, RAGCoreConnectionError
from conftest import build_search_app, run_app
class FlakyTransport(httpx.BaseTransport):
"""Returns a synthetic 503 for the first `fail_times` requests, then
delegates to a real HTTP transport pointed at the real running server.
Counts every request it sees.
"""
def __init__(self, *, fail_times: int) -> None:
self._inner = httpx.HTTPTransport()
self._fail_times = fail_times
self.call_count = 0
def handle_request(self, request: httpx.Request) -> httpx.Response:
self.call_count += 1
if self.call_count <= self._fail_times:
return httpx.Response(
503,
json={
"type": "https://ragcore.example.test/problems/search_unavailable",
"title": "Search unavailable",
"status": 503,
"code": "SEARCH_UNAVAILABLE",
"detail": "synthetic failure for retry test",
"trace_id": "test-trace",
},
)
return self._inner.handle_request(request)
class AlwaysDownTransport(httpx.BaseTransport):
def __init__(self) -> None:
self.call_count = 0
def handle_request(self, request: httpx.Request) -> httpx.Response:
self.call_count += 1
raise httpx.ConnectError("synthetic connection failure", request=request)
def test_retries_on_503_then_succeeds() -> None:
fixture = build_search_app()
with run_app(fixture.app) as base_url:
transport = FlakyTransport(fail_times=2)
client = RAGCoreClient(
base_url=base_url,
token="sdk-test-token",
transport=transport,
max_retries=3,
)
try:
response = client.search(query="refund", requested_space_ids=[fixture.space_id])
finally:
client.close()
assert transport.call_count == 3
assert response.results
def test_does_not_retry_a_client_error() -> None:
fixture = build_search_app()
with run_app(fixture.app) as base_url:
# fail_times=0: every request goes straight through to the real app,
# so this only counts calls -- the 422 below comes from the app's
# own request validation, not from the transport.
counting = FlakyTransport(fail_times=0)
client = RAGCoreClient(
base_url=base_url,
token="sdk-test-token",
transport=counting,
max_retries=3,
)
try:
with pytest.raises(RAGCoreAPIError) as excinfo:
client.search(query=" ")
finally:
client.close()
assert excinfo.value.problem.status == 422
assert counting.call_count == 1
def test_connection_failure_exhausts_retries_then_raises() -> None:
transport = AlwaysDownTransport()
client = RAGCoreClient(
base_url="http://127.0.0.1:1",
token="sdk-test-token",
transport=transport,
max_retries=2,
)
try:
with pytest.raises(RAGCoreConnectionError):
client.search(query="refund")
finally:
client.close()
assert transport.call_count == 3
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from uuid import uuid4
import pytest
from ragcore_sdk import RAGCoreAPIError, RAGCoreClient
from conftest import build_search_app, run_app
@contextmanager
def _client(app: object, *, token: str = "sdk-test-token") -> Iterator[RAGCoreClient]: # noqa: S107
with run_app(app) as base_url, RAGCoreClient(base_url=base_url, token=token) as client: # type: ignore[arg-type]
yield client
def test_search_returns_typed_results() -> None:
fixture = build_search_app()
with _client(fixture.app) as client:
response = client.search(query="refund", requested_space_ids=[fixture.space_id])
assert response.effective_space_ids == [fixture.space_id]
assert response.results[0].citation.excerpt_sha256
assert response.results[0].rank == 1
assert response.degraded is False
def test_search_out_of_scope_space_narrows_silently_to_empty() -> None:
fixture = build_search_app()
forbidden_space = uuid4()
with _client(fixture.app) as client:
response = client.search(query="refund", requested_space_ids=[forbidden_space])
assert response.effective_space_ids == []
assert response.results == []
def test_search_validation_error_raises_typed_problem() -> None:
fixture = build_search_app()
with _client(fixture.app) as client, pytest.raises(RAGCoreAPIError) as excinfo:
client.search(query=" ")
problem = excinfo.value.problem
assert problem.status == 422
assert problem.code == "VALIDATION_ERROR"
assert problem.trace_id
assert problem.errors
def test_bearer_token_never_appears_in_client_repr_or_error_message() -> None:
fixture = build_search_app()
secret_token = "super-secret-bearer-value-should-never-leak"
with _client(fixture.app, token=secret_token) as client:
assert secret_token not in repr(client)
with pytest.raises(RAGCoreAPIError) as excinfo:
client.search(query=" ")
assert secret_token not in str(excinfo.value)
problem = excinfo.value.problem
for field in problem.__slots__:
assert secret_token not in repr(getattr(problem, field))