ragfabric-sdk 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ # Environment
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # local configuration (copy of ragfabric.example.yaml)
7
+ ragfabric.yaml
8
+
9
+ # OS
10
+ .DS_Store
11
+
12
+ # Python
13
+ __pycache__/
14
+ *.pyc
15
+ *.pyo
16
+ .venv/
17
+ venv/
18
+ .pytest_cache/
19
+ *.egg-info/
20
+
21
+ # Local data / databases
22
+ *.db
23
+ backend/data/uploads/
24
+
25
+ # Node / Angular
26
+ node_modules/
27
+ dist/
28
+ .angular/
29
+ npm-debug.log*
30
+
31
+ # Editors
32
+ .idea/
33
+ .vscode/
34
+
35
+ # Frontend test artifacts
36
+ coverage/
37
+ out-tsc/
38
+
39
+ # Local planning notes and scratch (never committed)
40
+ MEMORY*.md
41
+ *.local.md
42
+ .notes/
43
+ .scratch/
44
+
45
+ # Secrets and keys (belt and braces; .env already excluded above)
46
+ *.pem
47
+ *.key
48
+ *.p12
49
+ secrets/
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.5
2
+ Name: ragfabric-sdk
3
+ Version: 0.3.1
4
+ Summary: Python client for a RagFabric server.
5
+ Project-URL: Homepage, https://github.com/ranjan-del/ragfabric
6
+ Project-URL: Repository, https://github.com/ranjan-del/ragfabric
7
+ Project-URL: Documentation, https://github.com/ranjan-del/ragfabric/tree/main/docs
8
+ Project-URL: Changelog, https://github.com/ranjan-del/ragfabric/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/ranjan-del/ragfabric/issues
10
+ Author-email: Ranjan G <ranjan.g@ispf.ngo>
11
+ License-Expression: Apache-2.0
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: <3.14,>=3.13
19
+ Requires-Dist: httpx>=0.28
20
+ Requires-Dist: pydantic>=2.13
21
+ Description-Content-Type: text/markdown
22
+
23
+ # ragfabric-sdk
24
+
25
+ Python client for a RagFabric server, talking HTTP only.
26
+
27
+ ```python
28
+ from ragfabric_sdk import Client
29
+
30
+ client = Client("http://localhost:8000", token="a-jwt-or-api-key")
31
+ answer = client.ask("What is the refund policy?")
32
+ print(answer.answer, answer.citations)
33
+ ```
@@ -0,0 +1,11 @@
1
+ # ragfabric-sdk
2
+
3
+ Python client for a RagFabric server, talking HTTP only.
4
+
5
+ ```python
6
+ from ragfabric_sdk import Client
7
+
8
+ client = Client("http://localhost:8000", token="a-jwt-or-api-key")
9
+ answer = client.ask("What is the refund policy?")
10
+ print(answer.answer, answer.citations)
11
+ ```
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "ragfabric-sdk"
3
+ version = "0.3.1"
4
+ description = "Python client for a RagFabric server."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13,<3.14"
7
+ classifiers = [
8
+ "Development Status :: 3 - Alpha",
9
+ "Intended Audience :: Developers",
10
+ "Operating System :: OS Independent",
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python :: 3.13",
13
+ "Topic :: Software Development :: Libraries :: Python Modules",
14
+ ]
15
+ license = "Apache-2.0"
16
+ authors = [{ name = "Ranjan G", email = "ranjan.g@ispf.ngo" }]
17
+ dependencies = ["httpx>=0.28", "pydantic>=2.13"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/ranjan-del/ragfabric"
21
+ Repository = "https://github.com/ranjan-del/ragfabric"
22
+ Documentation = "https://github.com/ranjan-del/ragfabric/tree/main/docs"
23
+ Changelog = "https://github.com/ranjan-del/ragfabric/blob/main/CHANGELOG.md"
24
+ Issues = "https://github.com/ranjan-del/ragfabric/issues"
25
+
26
+ [build-system]
27
+ requires = ["hatchling>=1.27"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/ragfabric_sdk"]
@@ -0,0 +1,46 @@
1
+ """ragfabric_sdk: a typed HTTP client for a RagFabric server.
2
+
3
+ Talks HTTP only and never imports ragfabric_core, ragfabric_server or
4
+ ragfabric_cli; see client.py for why.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ragfabric_sdk.client import Client
10
+ from ragfabric_sdk.errors import AuthError, NotFoundError, RagFabricError, RateLimitError
11
+ from ragfabric_sdk.models import (
12
+ Answer,
13
+ AskEvent,
14
+ Citation,
15
+ Document,
16
+ GraphEdge,
17
+ GraphNode,
18
+ Highlight,
19
+ Run,
20
+ SearchResult,
21
+ Source,
22
+ SourceDocument,
23
+ Span,
24
+ Subgraph,
25
+ )
26
+
27
+ __all__ = [
28
+ "Client",
29
+ "RagFabricError",
30
+ "AuthError",
31
+ "NotFoundError",
32
+ "RateLimitError",
33
+ "Answer",
34
+ "AskEvent",
35
+ "Citation",
36
+ "Document",
37
+ "GraphEdge",
38
+ "GraphNode",
39
+ "Highlight",
40
+ "Run",
41
+ "SearchResult",
42
+ "Source",
43
+ "SourceDocument",
44
+ "Span",
45
+ "Subgraph",
46
+ ]
@@ -0,0 +1,210 @@
1
+ """HTTP client for a RagFabric server.
2
+
3
+ Talks HTTP only and never imports ragfabric_core, ragfabric_server or
4
+ ragfabric_cli: an adopter installs this on a laptop or in a serverless
5
+ function to call a RagFabric server someone else runs. If it imported core
6
+ it would drag SQLAlchemy, pgvector, chromadb and the migrations into that
7
+ environment, and a version skew between the client's core and the server's
8
+ core would produce failures that look like API bugs. The rule is enforced
9
+ by an import linter contract, not by convention.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections.abc import Iterator
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+
20
+ from ragfabric_sdk.errors import raise_for_status
21
+ from ragfabric_sdk.models import Answer, AskEvent, Document, Run, SearchResult
22
+
23
+ DEFAULT_TIMEOUT = 30.0
24
+
25
+
26
+ class Client:
27
+ """A RagFabric API client.
28
+
29
+ Args:
30
+ base_url: e.g. "http://localhost:8000".
31
+ token: a JWT from POST /api/auth/login; sent as Authorization: Bearer.
32
+ api_key: an rf_ key; sent as X-API-Key. If both are given, the API
33
+ key wins and the bearer header is not sent.
34
+ timeout: seconds.
35
+ transport: a testing seam only, not a public feature. Pass an
36
+ ``httpx`` transport (for example ``httpx.MockTransport``) to
37
+ exercise this client against a fake server, so tests never touch
38
+ the network.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ base_url: str,
44
+ token: str | None = None,
45
+ api_key: str | None = None,
46
+ timeout: float = DEFAULT_TIMEOUT,
47
+ transport: httpx.BaseTransport | None = None,
48
+ ) -> None:
49
+ headers = {}
50
+ if api_key:
51
+ headers["X-API-Key"] = api_key
52
+ elif token:
53
+ headers["Authorization"] = f"Bearer {token}"
54
+ self._http = httpx.Client(
55
+ base_url=base_url.rstrip("/"),
56
+ headers=headers,
57
+ timeout=timeout,
58
+ transport=transport,
59
+ )
60
+
61
+ def __enter__(self) -> Client:
62
+ return self
63
+
64
+ def __exit__(self, *exc: object) -> None:
65
+ self.close()
66
+
67
+ def close(self) -> None:
68
+ self._http.close()
69
+
70
+ def ask(self, query: str, strategy: str = "traditional", **params) -> Answer:
71
+ """POST /api/ask with stream=False and return the finished, cited answer.
72
+
73
+ ``strategy`` names the retrieval strategy: ``"traditional"`` (embed the
74
+ question, search the vector index), ``"vectorless"`` (BM25 fused with
75
+ ts_rank_cd, no embedding call at all), ``"agentic"`` (decompose the
76
+ question, retrieve per part, repair or abandon the parts that fail) or
77
+ ``"graph"`` (see below).
78
+ It is spelled out as a named parameter rather than left to ``**params``
79
+ because it changes what the server does, and a caller should be able to
80
+ find it in the signature. The server rejects any other name with a 422.
81
+
82
+ With ``"agentic"``, the answer also carries ``sub_questions`` (what was
83
+ and was not answered, and why), ``dropped_claims`` (claims the citation
84
+ contract refused, removed rather than retried), ``dated_sources`` and
85
+ the agent's ``trace``. Every one of them is empty for the other
86
+ strategies, which have no parts to report.
87
+
88
+ With ``"graph"`` (walk the knowledge graph from the entities the
89
+ question names), the answer carries ``subgraph`` (the nodes and edges
90
+ walked, whether the walk was truncated, and why it was empty if it
91
+ was) and ``dropped_relationship_claims`` (relationship claims the graph
92
+ citation contract removed, each with its reason). ``subgraph`` is None
93
+ for every other strategy. A graph request with ``document_id`` or
94
+ ``format`` is refused with a 422, since the walk cannot apply them.
95
+ """
96
+ res = self._http.post(
97
+ "/api/ask",
98
+ json={"query": query, "stream": False, "strategy": strategy, **params},
99
+ )
100
+ raise_for_status(res)
101
+ return Answer.model_validate(res.json())
102
+
103
+ def ask_stream(self, query: str, strategy: str = "traditional", **params) -> Iterator[AskEvent]:
104
+ """POST /api/ask with stream=True and yield each server-sent event.
105
+
106
+ Events arrive in this order: ``retrieval``, one or more ``token``,
107
+ optionally ``superseded``, ``citations``, then ``done`` (carrying
108
+ ``run_id`` and ``latency_ms``).
109
+
110
+ ``superseded`` is not a failure: it fires when the streamed text
111
+ already rendered to the caller turns out to violate the citation
112
+ contract. The server cannot retake back the tokens it already sent,
113
+ so instead of silently recording a different answer than what was
114
+ shown, it repairs the answer and announces the repair. A caller
115
+ handling this event should REPLACE whatever it has drawn from the
116
+ preceding ``token`` events with ``event.data["text"]``, the same way
117
+ the ``ragfabric ask`` command does.
118
+
119
+ Per the SSE wire format, a ``data:`` line with no preceding
120
+ ``event:`` line for that block defaults to the event name
121
+ ``"message"``. The RagFabric server always sends an explicit
122
+ ``event:`` line today, but a client library follows the spec rather
123
+ than only the one server it was written against, so a bare
124
+ ``data:`` line is still yielded rather than silently dropped.
125
+ """
126
+ with self._http.stream(
127
+ "POST",
128
+ "/api/ask",
129
+ json={"query": query, "stream": True, "strategy": strategy, **params},
130
+ ) as res:
131
+ if res.status_code >= 400:
132
+ res.read()
133
+ raise_for_status(res)
134
+ name: str | None = None
135
+ for line in res.iter_lines():
136
+ if line.startswith("event:"):
137
+ name = line.split(":", 1)[1].strip()
138
+ elif line.startswith("data:"):
139
+ yield AskEvent(
140
+ event=name if name is not None else "message",
141
+ data=json.loads(line.split(":", 1)[1].strip()),
142
+ )
143
+ name = None
144
+
145
+ def search(
146
+ self,
147
+ query: str,
148
+ mode: str = "semantic",
149
+ strategy: str = "traditional",
150
+ **params,
151
+ ) -> list[SearchResult]:
152
+ """POST /api/search/semantic or /api/search/hybrid and return the ranked chunks.
153
+
154
+ ``strategy`` selects the retrieval strategy, as on ``ask``. Note that
155
+ ``mode="hybrid"`` fuses one vector ranking with one lexical ranking, so
156
+ the server refuses any strategy that does not provide that pair with a
157
+ 422; use the default ``mode="semantic"`` to search with
158
+ ``strategy="vectorless"``, ``strategy="agentic"`` or ``strategy="graph"``.
159
+ """
160
+ path = "/api/search/hybrid" if mode == "hybrid" else "/api/search/semantic"
161
+ res = self._http.post(
162
+ path, json={"query": query, "mode": mode, "strategy": strategy, **params}
163
+ )
164
+ raise_for_status(res)
165
+ return [SearchResult.model_validate(r) for r in res.json()["results"]]
166
+
167
+ def ingest(
168
+ self,
169
+ path: str | Path,
170
+ collection: int | None = None,
171
+ chunk_size: int | None = None,
172
+ chunk_overlap: int | None = None,
173
+ ) -> Document:
174
+ """POST /api/documents/upload and return the ingested document.
175
+
176
+ ``collection`` maps to the upload form's ``collection_id`` field.
177
+ ``chunk_size``/``chunk_overlap`` are the optional per-upload chunking
178
+ overrides the route accepts; left unset, the document is chunked
179
+ with the server's configured defaults.
180
+ """
181
+ file_path = Path(path)
182
+ data: dict[str, str] = {}
183
+ if collection is not None:
184
+ data["collection_id"] = str(collection)
185
+ if chunk_size is not None:
186
+ data["chunk_size"] = str(chunk_size)
187
+ if chunk_overlap is not None:
188
+ data["chunk_overlap"] = str(chunk_overlap)
189
+ with file_path.open("rb") as handle:
190
+ res = self._http.post(
191
+ "/api/documents/upload",
192
+ files={"file": (file_path.name, handle)},
193
+ data=data or None,
194
+ )
195
+ raise_for_status(res)
196
+ return Document.model_validate(res.json())
197
+
198
+ def documents(self) -> list[Document]:
199
+ """GET /api/documents. The route always returns the {"items": [...],
200
+ "total": N} envelope (schemas.document.DocumentList), never a bare
201
+ list, so that is the only shape parsed here."""
202
+ res = self._http.get("/api/documents")
203
+ raise_for_status(res)
204
+ return [Document.model_validate(r) for r in res.json()["items"]]
205
+
206
+ def run(self, run_id: int) -> Run:
207
+ """GET /api/runs/{id}."""
208
+ res = self._http.get(f"/api/runs/{run_id}")
209
+ raise_for_status(res)
210
+ return Run.model_validate(res.json())
@@ -0,0 +1,50 @@
1
+ """Exception types, mapped from HTTP status codes in one place.
2
+
3
+ Every client method funnels through ``raise_for_status`` so a caller can
4
+ catch one hierarchy (``RagFabricError``) or one specific cause
5
+ (``AuthError``, ``NotFoundError``, ``RateLimitError``) regardless of which
6
+ endpoint raised it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+
13
+
14
+ class RagFabricError(Exception):
15
+ """Base error for anything the server rejected. Carries the HTTP status
16
+ code and the response body's ``detail`` as the message."""
17
+
18
+ def __init__(self, message: str, status_code: int | None = None) -> None:
19
+ super().__init__(message)
20
+ self.status_code = status_code
21
+
22
+
23
+ class AuthError(RagFabricError):
24
+ """401 (not authenticated) or 403 (authenticated but not permitted)."""
25
+
26
+
27
+ class NotFoundError(RagFabricError):
28
+ """404: no such resource."""
29
+
30
+
31
+ class RateLimitError(RagFabricError):
32
+ """429: an API key went over its rate limit."""
33
+
34
+
35
+ def raise_for_status(response: httpx.Response) -> None:
36
+ """Raise the mapped error for a 4xx/5xx response; do nothing otherwise."""
37
+ if response.status_code < 400:
38
+ return
39
+ try:
40
+ detail = response.json().get("detail", response.text)
41
+ except ValueError:
42
+ detail = response.text
43
+ code = response.status_code
44
+ if code in (401, 403):
45
+ raise AuthError(str(detail), code)
46
+ if code == 404:
47
+ raise NotFoundError(str(detail), code)
48
+ if code == 429:
49
+ raise RateLimitError(str(detail), code)
50
+ raise RagFabricError(str(detail), code)
@@ -0,0 +1,281 @@
1
+ """Pydantic mirrors of the server's response schemas.
2
+
3
+ Each class here shadows one schema in
4
+ ``packages/server/src/ragfabric_server/schemas/``. Field names and types are
5
+ copied from the real schema modules, not from the plan that first described
6
+ this client, because the schemas are the source of truth and drift between
7
+ the two is exactly what these models exist to catch early (a validation
8
+ error at parse time, not a confusing ``KeyError`` deep in caller code).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import datetime
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ class Highlight(BaseModel):
19
+ """A query-term span, in ``schemas/search.py``."""
20
+
21
+ term: str
22
+ start: int
23
+ end: int
24
+
25
+
26
+ class Span(BaseModel):
27
+ """A character range plus the text it covers, in ``schemas/search.py``."""
28
+
29
+ text: str
30
+ start: int
31
+ end: int
32
+
33
+
34
+ class Citation(BaseModel):
35
+ """Mirrors ``schemas.search.Citation``."""
36
+
37
+ marker: str
38
+ chunk_id: int | None = None
39
+ document_id: int | None = None
40
+ filename: str | None = None
41
+ page: int | None = None
42
+ # None for the graph strategy, whose chunks come from a traversal and
43
+ # carry no similarity score.
44
+ score: float | None = None
45
+ snippet: str
46
+ used: bool = False
47
+ highlights: list[Highlight] = Field(default_factory=list)
48
+ supporting_span: Span | None = None
49
+
50
+
51
+ class SourceDocument(BaseModel):
52
+ """Mirrors ``schemas.search.SourceDocument``."""
53
+
54
+ document_id: int | None = None
55
+ filename: str | None = None
56
+ page: int | None = None
57
+ collection_id: int | None = None
58
+
59
+
60
+ class Usage(BaseModel):
61
+ """Mirrors ``schemas.search.Usage``: what the request actually cost.
62
+
63
+ ``embedding_calls`` is zero for the vectorless strategy, which makes none,
64
+ and one for the traditional strategy, which embeds the query exactly once.
65
+ """
66
+
67
+ embedding_calls: int = 0
68
+ llm_calls: int = 0
69
+ retrieval_calls: int = 0
70
+ input_tokens: int = 0
71
+ output_tokens: int = 0
72
+
73
+
74
+ class SubQuestionReport(BaseModel):
75
+ """Mirrors ``schemas.search.SubQuestionReportOut``.
76
+
77
+ Only the agentic strategy fills this in. ``status`` is ``answered``,
78
+ ``abandoned`` or ``open``, where ``open`` means the run stopped with this
79
+ part of the question unanswered, and ``reason`` says what stopped it. It
80
+ replaces what a single "this answer is partial" flag could not express:
81
+ which part failed, and why.
82
+ """
83
+
84
+ text: str
85
+ status: str
86
+ reason: str | None = None
87
+ chunk_ids: list[int] = Field(default_factory=list)
88
+
89
+
90
+ class DroppedClaim(BaseModel):
91
+ """Mirrors ``schemas.search.DroppedClaimOut``: a claim the contract refused.
92
+
93
+ The agent removes an unsupported claim rather than retrying retrieval for
94
+ it, and reports the removal here so the caller can see what was cut.
95
+ """
96
+
97
+ text: str
98
+ reason: str
99
+
100
+
101
+ class DatedSource(BaseModel):
102
+ """Mirrors ``schemas.search.DatedSourceOut``."""
103
+
104
+ marker: int
105
+ chunk_id: int
106
+ document_id: int
107
+ effective_date: str
108
+
109
+
110
+ class DatedSubQuestion(BaseModel):
111
+ """Mirrors ``schemas.search.DatedSubQuestionOut``.
112
+
113
+ Sources for one part of the question carrying different effective dates.
114
+ This is metadata: nothing here says they disagree in meaning.
115
+ """
116
+
117
+ sub_question: str
118
+ sources: list[DatedSource] = Field(default_factory=list)
119
+
120
+
121
+ class GraphNode(BaseModel):
122
+ """Mirrors ``schemas.search.GraphNodeOut``: an entity the graph walk reached.
123
+
124
+ Names and types only; the server never sends a stored description.
125
+ ``entity_type`` stays a plain string so a type added on the server does not
126
+ make this client reject the response.
127
+ """
128
+
129
+ id: int
130
+ name: str
131
+ entity_type: str
132
+ depth: int
133
+
134
+
135
+ class GraphEdge(BaseModel):
136
+ """Mirrors ``schemas.search.GraphEdgeOut``: an edge the walk kept.
137
+
138
+ ``walked_as`` is the relation as walked (the inverse name when
139
+ ``reversed``). ``confidence`` is None when nothing measured it.
140
+ """
141
+
142
+ id: int
143
+ source_id: int
144
+ target_id: int
145
+ relation_type: str
146
+ walked_as: str
147
+ reversed: bool
148
+ confidence: float | None = None
149
+ source_chunk_ids: list[int] = Field(default_factory=list)
150
+
151
+
152
+ class Subgraph(BaseModel):
153
+ """Mirrors ``schemas.search.SubgraphOut``: what the graph strategy walked.
154
+
155
+ ``empty_reason`` is ``no_graph_coverage``, ``no_entity_matched`` or
156
+ ``no_walkable_edges`` when nothing was walked, and None otherwise.
157
+ ``truncated`` is True when the server's node budget cut the walk.
158
+ """
159
+
160
+ nodes: list[GraphNode] = Field(default_factory=list)
161
+ edges: list[GraphEdge] = Field(default_factory=list)
162
+ truncated: bool = False
163
+ empty_reason: str | None = None
164
+
165
+
166
+ class Answer(BaseModel):
167
+ """Mirrors ``schemas.search.AnswerResponse``, returned by both
168
+ ``POST /api/ask`` (non streaming) and ``POST /api/search/query``."""
169
+
170
+ question: str
171
+ answer: str
172
+ confidence: float
173
+ citations: list[Citation]
174
+ highlights: list[Highlight]
175
+ source_document: SourceDocument | None = None
176
+ # Optional so this client still parses a response from a server older
177
+ # than Phase 4, which has no usage block to report.
178
+ usage: Usage | None = None
179
+ # Empty unless the agentic strategy served the request, and empty rather
180
+ # than optional so a server older than Phase 5 parses without special
181
+ # cases. ``trace`` stays a list of plain dicts for the same reason
182
+ # ``Run.trace`` does: a span's attributes differ per node and typing them
183
+ # would make the client reject a span shape it simply has not seen.
184
+ sub_questions: list[SubQuestionReport] = Field(default_factory=list)
185
+ dropped_claims: list[DroppedClaim] = Field(default_factory=list)
186
+ dated_sources: list[DatedSubQuestion] = Field(default_factory=list)
187
+ trace: list[dict] = Field(default_factory=list)
188
+ # Only the graph strategy fills these in; optional and empty by default so
189
+ # a server older than Phase 6 parses unchanged. ``dropped_relationship_claims``
190
+ # are relationship claims the graph citation contract removed, with the
191
+ # rule that removed each one as ``reason``.
192
+ subgraph: Subgraph | None = None
193
+ dropped_relationship_claims: list[DroppedClaim] = Field(default_factory=list)
194
+
195
+
196
+ class SearchResult(BaseModel):
197
+ """Mirrors ``schemas.search.SearchResultItem``."""
198
+
199
+ chunk_id: int | None = None
200
+ document_id: int | None = None
201
+ filename: str | None = None
202
+ format: str | None = None
203
+ page: int | None = None
204
+ chunk_index: int | None = None
205
+ score: float
206
+ lexical_score: float | None = None
207
+ hybrid_score: float | None = None
208
+ text: str
209
+
210
+
211
+ class Document(BaseModel):
212
+ """Mirrors ``schemas.document.DocumentOut``, returned by
213
+ ``POST /api/documents/upload``, ``GET /api/documents`` and
214
+ ``GET /api/documents/{id}``."""
215
+
216
+ id: int
217
+ filename: str
218
+ format: str
219
+ document_type: str = ""
220
+ storage_path: str | None = None
221
+ content_type: str
222
+ status: str
223
+ collection_id: int | None = None
224
+ owner_id: int | None = None
225
+ version: int
226
+ num_chunks: int
227
+ error: str
228
+ created_at: datetime
229
+
230
+
231
+ class Source(BaseModel):
232
+ """Mirrors ``schemas.runs.SourceOut``, one row of ``Run.sources``."""
233
+
234
+ rank: int
235
+ chunk_id: int | None = None
236
+ document_id: int | None = None
237
+ score: float | None = None
238
+ cited: bool
239
+ page: int | None = None
240
+
241
+
242
+ class Run(BaseModel):
243
+ """Mirrors ``schemas.runs.RunOut``, returned by ``GET /api/runs/{id}``."""
244
+
245
+ id: int
246
+ question: str
247
+ mode: str
248
+ requested_strategy: str | None = None
249
+ selected_strategy: str
250
+ fallback_from: str | None = None
251
+ answer: str | None = None
252
+ latency_ms: int
253
+ retrieval_latency_ms: int
254
+ generation_latency_ms: int
255
+ llm_calls: int
256
+ retrieval_calls: int
257
+ input_tokens: int
258
+ output_tokens: int
259
+ estimated_cost_usd: float | None = None
260
+ llm_model: str | None = None
261
+ embedding_model: str | None = None
262
+ trace: list[dict] = Field(default_factory=list)
263
+ created_at: datetime
264
+ sources: list[Source] = Field(default_factory=list)
265
+
266
+
267
+ class AskEvent(BaseModel):
268
+ """One SSE event from ``POST /api/ask`` with ``stream: true``.
269
+
270
+ ``event`` is one of ``retrieval``, ``token``, ``superseded``,
271
+ ``citations`` or ``done``, in that order (``superseded`` only appears
272
+ when the streamed answer failed the citation contract). With the graph
273
+ strategy, ``retrieval`` carries ``subgraph`` (a ``Subgraph`` as a dict),
274
+ and ``superseded`` carries ``dropped_claims`` and
275
+ ``dropped_relationship_claims`` alongside the repaired ``text``. ``data`` is the
276
+ event's raw JSON payload, kept untyped because each event name carries a
277
+ different shape and this class is a thin parsing result, not a schema.
278
+ """
279
+
280
+ event: str
281
+ data: dict
@@ -0,0 +1,547 @@
1
+ """Tests for the ragfabric_sdk client, against httpx.MockTransport only.
2
+
3
+ No test here talks to a network or a running server: every handler below
4
+ builds the response by hand, shaped exactly like the real server's routes in
5
+ packages/server/src/ragfabric_server/api/routes/ and schemas in
6
+ packages/server/src/ragfabric_server/schemas/.
7
+ """
8
+
9
+ import email
10
+ import json
11
+
12
+ import httpx
13
+ import pytest
14
+
15
+ from ragfabric_sdk import Client
16
+ from ragfabric_sdk.errors import AuthError, NotFoundError, RateLimitError
17
+ from ragfabric_sdk.models import Document
18
+
19
+
20
+ def transport(handler):
21
+ return httpx.MockTransport(handler)
22
+
23
+
24
+ def client_with(handler, **kwargs):
25
+ return Client("http://server", token="t", transport=transport(handler), **kwargs)
26
+
27
+
28
+ def parse_multipart(request: httpx.Request) -> dict[str, str | bytes]:
29
+ """Decode a MockTransport-captured multipart/form-data request into a
30
+ {field name: value} dict, keyed exactly by the field's ``name`` so
31
+ "collection" and "collection_id" are never confused with one another.
32
+
33
+ Uses the standard library's ``email`` parser rather than substring
34
+ matching on the raw bytes, since a substring check for ``name="x"``
35
+ would also match inside ``name="x_id"``.
36
+ """
37
+ content_type = request.headers["content-type"]
38
+ raw = b"Content-Type: " + content_type.encode() + b"\r\n\r\n" + request.content
39
+ message = email.message_from_bytes(raw)
40
+ fields: dict[str, str | bytes] = {}
41
+ for part in message.get_payload():
42
+ name = part.get_param("name", header="Content-Disposition")
43
+ payload = part.get_payload(decode=True)
44
+ fields[name] = payload if part.get_filename() else payload.decode()
45
+ return fields
46
+
47
+
48
+ _DOCUMENT_RESPONSE = {
49
+ "id": 1,
50
+ "filename": "doc.txt",
51
+ "format": "txt",
52
+ "document_type": "",
53
+ "storage_path": None,
54
+ "content_type": "text/plain",
55
+ "status": "ready",
56
+ "collection_id": 42,
57
+ "owner_id": 1,
58
+ "version": 1,
59
+ "num_chunks": 2,
60
+ "error": "",
61
+ "created_at": "2026-09-18T00:00:00",
62
+ }
63
+
64
+
65
+ def test_ask_returns_a_typed_answer():
66
+ def handler(request):
67
+ assert request.url.path == "/api/ask"
68
+ assert json.loads(request.content)["stream"] is False
69
+ return httpx.Response(
70
+ 200,
71
+ json={
72
+ "question": "q",
73
+ "answer": "a [1]",
74
+ "confidence": 0.8,
75
+ "citations": [
76
+ {"marker": "[1]", "chunk_id": 3, "document_id": 1, "score": 0.9, "snippet": "s"}
77
+ ],
78
+ "highlights": [],
79
+ },
80
+ )
81
+
82
+ answer = client_with(handler).ask("q")
83
+ assert answer.answer == "a [1]"
84
+ assert answer.citations[0].chunk_id == 3
85
+ assert answer.confidence == pytest.approx(0.8)
86
+ # Fields absent from the response body fall back to the schema's
87
+ # defaults rather than raising.
88
+ assert answer.citations[0].used is False
89
+ assert answer.citations[0].filename is None
90
+ assert answer.source_document is None
91
+
92
+
93
+ def test_the_bearer_token_is_sent():
94
+ def handler(request):
95
+ assert request.headers["authorization"] == "Bearer t"
96
+ return httpx.Response(
97
+ 200,
98
+ json={
99
+ "question": "q",
100
+ "answer": "",
101
+ "confidence": 0.0,
102
+ "citations": [],
103
+ "highlights": [],
104
+ },
105
+ )
106
+
107
+ client_with(handler).ask("q")
108
+
109
+
110
+ def test_an_api_key_is_sent_in_the_x_api_key_header():
111
+ def handler(request):
112
+ assert request.headers["x-api-key"] == "rf_abc"
113
+ assert "authorization" not in request.headers
114
+ return httpx.Response(
115
+ 200,
116
+ json={
117
+ "question": "q",
118
+ "answer": "",
119
+ "confidence": 0.0,
120
+ "citations": [],
121
+ "highlights": [],
122
+ },
123
+ )
124
+
125
+ Client("http://server", api_key="rf_abc", transport=transport(handler)).ask("q")
126
+
127
+
128
+ def test_an_api_key_wins_when_both_are_given():
129
+ """The class docstring documents this precedence; pin it with a test."""
130
+
131
+ def handler(request):
132
+ assert request.headers["x-api-key"] == "rf_abc"
133
+ assert "authorization" not in request.headers
134
+ return httpx.Response(
135
+ 200,
136
+ json={
137
+ "question": "q",
138
+ "answer": "",
139
+ "confidence": 0.0,
140
+ "citations": [],
141
+ "highlights": [],
142
+ },
143
+ )
144
+
145
+ Client("http://server", token="t", api_key="rf_abc", transport=transport(handler)).ask("q")
146
+
147
+
148
+ def test_ask_stream_yields_parsed_events():
149
+ body = (
150
+ 'event: retrieval\ndata: {"chunks": 2}\n\n'
151
+ 'event: token\ndata: {"text": "hello "}\n\n'
152
+ 'event: token\ndata: {"text": "world"}\n\n'
153
+ 'event: citations\ndata: {"citations": []}\n\n'
154
+ 'event: done\ndata: {"run_id": 7, "latency_ms": 12}\n\n'
155
+ )
156
+
157
+ def handler(request):
158
+ return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"})
159
+
160
+ events = list(client_with(handler).ask_stream("q"))
161
+ assert [e.event for e in events] == ["retrieval", "token", "token", "citations", "done"]
162
+ assert "".join(e.data["text"] for e in events if e.event == "token") == "hello world"
163
+ assert events[-1].data["run_id"] == 7
164
+
165
+
166
+ def test_ask_stream_surfaces_a_superseded_event():
167
+ """A citation contract violation on the streamed text is reported as a
168
+ superseded event carrying the corrected text, not silently swallowed and
169
+ not raised as an error: the SDK must pass it through so a caller can
170
+ replace what it already rendered."""
171
+ body = (
172
+ 'event: retrieval\ndata: {"chunks": 1}\n\n'
173
+ 'event: token\ndata: {"text": "unverified claim"}\n\n'
174
+ 'event: superseded\ndata: {"text": "corrected [1] answer", "reason": "citation contract"}\n\n'
175
+ 'event: citations\ndata: {"citations": []}\n\n'
176
+ 'event: done\ndata: {"run_id": 9, "latency_ms": 40}\n\n'
177
+ )
178
+
179
+ def handler(request):
180
+ return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"})
181
+
182
+ events = list(client_with(handler).ask_stream("q"))
183
+ assert [e.event for e in events] == ["retrieval", "token", "superseded", "citations", "done"]
184
+ superseded = events[2]
185
+ assert superseded.data["text"] == "corrected [1] answer"
186
+ assert superseded.data["reason"] == "citation contract"
187
+
188
+
189
+ def test_ask_stream_defaults_a_bare_data_line_to_the_message_event():
190
+ """Per the SSE wire format, a data: line with no preceding event: line
191
+ defaults to the event name "message". The real server always sends an
192
+ explicit event: line today, but the parser should follow the spec, not
193
+ just the one server it was written against."""
194
+ body = 'data: {"chunks": 2}\n\n'
195
+
196
+ def handler(request):
197
+ return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"})
198
+
199
+ events = list(client_with(handler).ask_stream("q"))
200
+ assert len(events) == 1
201
+ assert events[0].event == "message"
202
+ assert events[0].data == {"chunks": 2}
203
+
204
+
205
+ def test_a_401_becomes_an_auth_error():
206
+ def handler(request):
207
+ return httpx.Response(401, json={"detail": "not authenticated"})
208
+
209
+ with pytest.raises(AuthError):
210
+ client_with(handler).ask("q")
211
+
212
+
213
+ def test_a_403_also_becomes_an_auth_error():
214
+ def handler(request):
215
+ return httpx.Response(403, json={"detail": "insufficient permissions"})
216
+
217
+ with pytest.raises(AuthError) as exc_info:
218
+ client_with(handler).ask("q")
219
+ assert "insufficient permissions" in str(exc_info.value)
220
+
221
+
222
+ def test_a_429_becomes_a_rate_limit_error():
223
+ def handler(request):
224
+ return httpx.Response(429, json={"detail": "too many"})
225
+
226
+ with pytest.raises(RateLimitError):
227
+ client_with(handler).ask("q")
228
+
229
+
230
+ def test_a_404_becomes_a_not_found_error():
231
+ def handler(request):
232
+ return httpx.Response(404, json={"detail": "no such run"})
233
+
234
+ with pytest.raises(NotFoundError):
235
+ client_with(handler).run(99)
236
+
237
+
238
+ def test_search_returns_typed_results():
239
+ def handler(request):
240
+ assert request.url.path == "/api/search/semantic"
241
+ return httpx.Response(
242
+ 200,
243
+ json={
244
+ "query": "q",
245
+ "mode": "semantic",
246
+ "results": [{"chunk_id": 1, "document_id": 2, "score": 0.5, "text": "t"}],
247
+ },
248
+ )
249
+
250
+ results = client_with(handler).search("q")
251
+ assert results[0].score == pytest.approx(0.5)
252
+ assert results[0].text == "t"
253
+
254
+
255
+ def test_search_hybrid_uses_the_hybrid_path():
256
+ def handler(request):
257
+ assert request.url.path == "/api/search/hybrid"
258
+ return httpx.Response(
259
+ 200,
260
+ json={"query": "q", "mode": "hybrid", "results": []},
261
+ )
262
+
263
+ client_with(handler).search("q", mode="hybrid")
264
+
265
+
266
+ def test_ingest_sends_the_real_multipart_shape(tmp_path):
267
+ """The plan's reference sent the collection as a form field named
268
+ "collection"; the real upload route (api/routes/documents.py) reads
269
+ "collection_id", "chunk_size" and "chunk_overlap" as Form fields. This
270
+ test inspects the outgoing request rather than only the parsed response,
271
+ so a regression back to the wrong field name would be caught here."""
272
+ file_path = tmp_path / "doc.txt"
273
+ file_path.write_text("hello world")
274
+ captured: dict[str, httpx.Request] = {}
275
+
276
+ def handler(request):
277
+ assert request.url.path == "/api/documents/upload"
278
+ captured["request"] = request
279
+ return httpx.Response(201, json=_DOCUMENT_RESPONSE)
280
+
281
+ doc = client_with(handler).ingest(file_path, collection=42, chunk_size=500)
282
+
283
+ fields = parse_multipart(captured["request"])
284
+ assert fields["file"] == b"hello world"
285
+ assert fields["collection_id"] == "42"
286
+ assert "collection" not in fields
287
+ assert fields["chunk_size"] == "500"
288
+ # chunk_overlap was never supplied: the server treats an absent field as
289
+ # "use the configured value", which is a different thing from a form
290
+ # field carrying an empty string or the literal text "None".
291
+ assert "chunk_overlap" not in fields
292
+ assert isinstance(doc, Document)
293
+ assert doc.filename == "doc.txt"
294
+
295
+
296
+ def test_ingest_omits_every_optional_field_when_none_are_given(tmp_path):
297
+ """With no collection/chunk_size/chunk_overlap supplied, the client must
298
+ send only the file: it must not stringify None into any of these form
299
+ fields."""
300
+ file_path = tmp_path / "doc.txt"
301
+ file_path.write_text("hello world")
302
+ captured: dict[str, httpx.Request] = {}
303
+
304
+ def handler(request):
305
+ captured["request"] = request
306
+ return httpx.Response(201, json=_DOCUMENT_RESPONSE)
307
+
308
+ client_with(handler).ingest(file_path)
309
+
310
+ fields = parse_multipart(captured["request"])
311
+ assert set(fields) == {"file"}
312
+
313
+
314
+ def test_documents_parses_the_items_envelope():
315
+ """GET /api/documents always returns {"items": [...], "total": N}
316
+ (schemas.document.DocumentList); there is no bare-list shape to
317
+ tolerate."""
318
+
319
+ def handler(request):
320
+ assert request.url.path == "/api/documents"
321
+ return httpx.Response(
322
+ 200,
323
+ json={
324
+ "items": [
325
+ {
326
+ "id": 1,
327
+ "filename": "a.pdf",
328
+ "format": "pdf",
329
+ "document_type": "",
330
+ "storage_path": None,
331
+ "content_type": "application/pdf",
332
+ "status": "ready",
333
+ "collection_id": None,
334
+ "owner_id": 1,
335
+ "version": 1,
336
+ "num_chunks": 3,
337
+ "error": "",
338
+ "created_at": "2026-09-18T00:00:00",
339
+ }
340
+ ],
341
+ "total": 1,
342
+ },
343
+ )
344
+
345
+ docs = client_with(handler).documents()
346
+ assert len(docs) == 1
347
+ assert docs[0].filename == "a.pdf"
348
+ assert docs[0].num_chunks == 3
349
+
350
+
351
+ def test_run_returns_a_typed_run_with_sources():
352
+ def handler(request):
353
+ assert request.url.path == "/api/runs/7"
354
+ return httpx.Response(
355
+ 200,
356
+ json={
357
+ "id": 7,
358
+ "question": "q",
359
+ "mode": "manual",
360
+ "requested_strategy": "traditional",
361
+ "selected_strategy": "traditional",
362
+ "fallback_from": None,
363
+ "answer": "a",
364
+ "latency_ms": 100,
365
+ "retrieval_latency_ms": 40,
366
+ "generation_latency_ms": 60,
367
+ "llm_calls": 1,
368
+ "retrieval_calls": 1,
369
+ "input_tokens": 10,
370
+ "output_tokens": 20,
371
+ "estimated_cost_usd": 0.0,
372
+ "llm_model": None,
373
+ "embedding_model": "hashing",
374
+ "trace": [],
375
+ "created_at": "2026-09-18T00:00:00",
376
+ "sources": [
377
+ {
378
+ "rank": 1,
379
+ "chunk_id": 3,
380
+ "document_id": 1,
381
+ "score": 0.9,
382
+ "cited": True,
383
+ "page": None,
384
+ }
385
+ ],
386
+ },
387
+ )
388
+
389
+ run = client_with(handler).run(7)
390
+ assert run.id == 7
391
+ assert run.sources[0].chunk_id == 3
392
+ assert run.sources[0].cited is True
393
+
394
+
395
+ def test_ask_sends_the_agentic_strategy_and_parses_the_sub_question_report():
396
+ """The agent's partial answers are structured, so the client types them."""
397
+
398
+ def handler(request):
399
+ assert json.loads(request.content)["strategy"] == "agentic"
400
+ return httpx.Response(
401
+ 200,
402
+ json={
403
+ "question": "q",
404
+ "answer": "a [1]",
405
+ "confidence": 0.8,
406
+ "citations": [],
407
+ "highlights": [],
408
+ "sub_questions": [
409
+ {
410
+ "text": "what is the retry limit",
411
+ "status": "answered",
412
+ "reason": None,
413
+ "chunk_ids": [3],
414
+ },
415
+ {
416
+ "text": "who signs it off",
417
+ "status": "open",
418
+ "reason": "no evidence was retrieved for this sub-question",
419
+ "chunk_ids": [],
420
+ },
421
+ ],
422
+ "dropped_claims": [{"text": "the board approved it", "reason": "uncited answer"}],
423
+ "dated_sources": [
424
+ {
425
+ "sub_question": "what is the retry limit",
426
+ "sources": [
427
+ {
428
+ "marker": 1,
429
+ "chunk_id": 3,
430
+ "document_id": 7,
431
+ "effective_date": "2025-06-01",
432
+ }
433
+ ],
434
+ }
435
+ ],
436
+ "trace": [{"name": "plan", "started_ms": 0, "duration_ms": 2, "attributes": {}}],
437
+ },
438
+ )
439
+
440
+ answer = client_with(handler).ask("q", strategy="agentic")
441
+
442
+ assert [report.status for report in answer.sub_questions] == ["answered", "open"]
443
+ assert answer.sub_questions[1].reason
444
+ assert answer.dropped_claims[0].reason == "uncited answer"
445
+ assert answer.dated_sources[0].sources[0].effective_date == "2025-06-01"
446
+ assert answer.trace[0]["name"] == "plan"
447
+
448
+
449
+ def test_an_answer_from_a_server_without_the_agent_fields_still_parses():
450
+ """Every field this task adds is optional, so an older server is readable."""
451
+
452
+ def handler(request):
453
+ return httpx.Response(
454
+ 200,
455
+ json={
456
+ "question": "q",
457
+ "answer": "a [1]",
458
+ "confidence": 0.8,
459
+ "citations": [],
460
+ "highlights": [],
461
+ },
462
+ )
463
+
464
+ answer = client_with(handler).ask("q")
465
+
466
+ assert answer.sub_questions == []
467
+ assert answer.dropped_claims == []
468
+ assert answer.dated_sources == []
469
+ assert answer.trace == []
470
+ assert answer.subgraph is None
471
+ assert answer.dropped_relationship_claims == []
472
+
473
+
474
+ def test_ask_sends_the_graph_strategy_and_parses_the_subgraph():
475
+ def handler(request):
476
+ assert json.loads(request.content)["strategy"] == "graph"
477
+ return httpx.Response(
478
+ 200,
479
+ json={
480
+ "question": "q",
481
+ "answer": "The Platform Team is a member of Engineering [E 1] [1].",
482
+ "confidence": 0.8,
483
+ "citations": [],
484
+ "highlights": [],
485
+ "subgraph": {
486
+ "nodes": [
487
+ {"id": 1, "name": "Platform Team", "entity_type": "team", "depth": 0},
488
+ {"id": 2, "name": "Engineering", "entity_type": "organisation", "depth": 1},
489
+ ],
490
+ "edges": [
491
+ {
492
+ "id": 5,
493
+ "source_id": 1,
494
+ "target_id": 2,
495
+ "relation_type": "MEMBER_OF",
496
+ "walked_as": "MEMBER_OF",
497
+ "reversed": False,
498
+ "confidence": None,
499
+ "source_chunk_ids": [9],
500
+ }
501
+ ],
502
+ "truncated": True,
503
+ "empty_reason": None,
504
+ },
505
+ "dropped_relationship_claims": [
506
+ {
507
+ "text": "Engineering reports to the Board [E 99] [1].",
508
+ "reason": "edge_not_in_subgraph",
509
+ }
510
+ ],
511
+ },
512
+ )
513
+
514
+ answer = client_with(handler).ask("q", strategy="graph")
515
+
516
+ assert [node.name for node in answer.subgraph.nodes] == ["Platform Team", "Engineering"]
517
+ assert answer.subgraph.nodes[1].depth == 1
518
+ [edge] = answer.subgraph.edges
519
+ assert (edge.walked_as, edge.reversed, edge.confidence) == ("MEMBER_OF", False, None)
520
+ assert edge.source_chunk_ids == [9]
521
+ assert answer.subgraph.truncated is True
522
+ assert answer.subgraph.empty_reason is None
523
+ assert answer.dropped_relationship_claims[0].reason == "edge_not_in_subgraph"
524
+
525
+
526
+ def test_an_empty_subgraph_reports_why():
527
+ def handler(request):
528
+ return httpx.Response(
529
+ 200,
530
+ json={
531
+ "question": "q",
532
+ "answer": "I could not find an answer to that in the documents provided.",
533
+ "confidence": 0.0,
534
+ "citations": [],
535
+ "highlights": [],
536
+ "subgraph": {
537
+ "nodes": [],
538
+ "edges": [],
539
+ "truncated": False,
540
+ "empty_reason": "no_graph_coverage",
541
+ },
542
+ },
543
+ )
544
+
545
+ answer = client_with(handler).ask("q", strategy="graph")
546
+
547
+ assert answer.subgraph.empty_reason == "no_graph_coverage"