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.
- ragfabric_sdk-0.3.1/.gitignore +49 -0
- ragfabric_sdk-0.3.1/PKG-INFO +33 -0
- ragfabric_sdk-0.3.1/README.md +11 -0
- ragfabric_sdk-0.3.1/pyproject.toml +31 -0
- ragfabric_sdk-0.3.1/src/ragfabric_sdk/__init__.py +46 -0
- ragfabric_sdk-0.3.1/src/ragfabric_sdk/client.py +210 -0
- ragfabric_sdk-0.3.1/src/ragfabric_sdk/errors.py +50 -0
- ragfabric_sdk-0.3.1/src/ragfabric_sdk/models.py +281 -0
- ragfabric_sdk-0.3.1/tests/test_client.py +547 -0
|
@@ -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"
|